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,540 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module WebUntis
9
+ # A WebUntis session: logs in, keeps cookies and the JWT fresh, and exposes
10
+ # the {#rpc} and {#rest} namespaces.
11
+ class Client
12
+ DEFAULT_CLIENT_NAME = "webuntis-api"
13
+ TOKEN_SKEW = 30
14
+ TOKEN_FALLBACK_TTL = 600
15
+ ANONYMOUS_USER = "#anonymous#"
16
+ ANONYMOUS_OTP = "100170"
17
+ APP_DATA_PATH = "api/rest/view/v1/app/data"
18
+ LOGIN_MODES = %i[rpc form secret anonymous].freeze
19
+
20
+ # The current {Session}, the cookie jar, the logger, the `client` name sent to `authenticate`
21
+ # and the login flow in use (`:rpc`, `:form`, `:secret` or `:anonymous`).
22
+ attr_reader :session, :cookies, :logger, :client_name, :login_mode
23
+
24
+ def initialize(school:, username: nil, password: nil, secret: nil, anonymous: false,
25
+ server: nil, tenant_id: nil, login: nil, school_year_id: nil,
26
+ client_name: DEFAULT_CLIENT_NAME, http: nil, logger: nil,
27
+ open_timeout: 10, read_timeout: 30, now: nil)
28
+ @school_query = school
29
+ @username = username
30
+ @password = password
31
+ @secret = secret
32
+ @anonymous = anonymous
33
+ @server = server
34
+ @configured_tenant_id = tenant_id
35
+ @login_mode = validate_login_mode(login || derive_login_mode)
36
+ @school_year_id = school_year_id
37
+ @client_name = client_name
38
+ @http = http || HTTP::NetHttp.new(open_timeout: open_timeout, read_timeout: read_timeout)
39
+ @logger = logger
40
+ @now = now || -> { Time.now }
41
+ @session = Session.new
42
+ @cookies = CookieJar.new
43
+ @logged_in = false
44
+ end
45
+
46
+ # The resolved tenant; discovered via {School.find} when no `server:` was given.
47
+ def school
48
+ @school ||= @server ? explicit_school : School.find(@school_query, http: @http)
49
+ end
50
+
51
+ # The `https://<host>/WebUntis` prefix every request is built from.
52
+ def base_url
53
+ school.base_url
54
+ end
55
+
56
+ # The JSON-RPC namespace (`jsonrpc.do`).
57
+ def rpc
58
+ @rpc ||= RPC.new(self)
59
+ end
60
+
61
+ # The modern REST namespace (`api/rest/view/...` plus the legacy `api/...` endpoints).
62
+ def rest
63
+ @rest ||= REST.new(self)
64
+ end
65
+
66
+ # True once a login succeeded and the session has not been invalidated since.
67
+ def logged_in?
68
+ @logged_in
69
+ end
70
+
71
+ # Logs in with the configured flow, replacing any existing session.
72
+ def login!
73
+ reset_state!
74
+ case @login_mode
75
+ when :rpc then login_rpc!
76
+ when :form then login_form!
77
+ when :secret then login_otp!(TOTP.code(@secret, at: now), @username)
78
+ when :anonymous then login_otp!(ANONYMOUS_OTP, ANONYMOUS_USER)
79
+ end
80
+ @logged_in = true
81
+ @session.tenant_id ||= tenant_id_without_app_data
82
+ self
83
+ end
84
+
85
+ # Ends the session via the `logout` JSON-RPC method and drops all local state.
86
+ def logout!
87
+ return self unless @logged_in
88
+
89
+ begin
90
+ rpc_post("logout", {})
91
+ rescue Error
92
+ nil
93
+ end
94
+ reset_state!
95
+ self
96
+ end
97
+
98
+ # Logs in unless a session is already open.
99
+ def ensure_login!
100
+ login! unless @logged_in
101
+ self
102
+ end
103
+
104
+ # The tenant id, from the constructor, the `Tenant-Id` cookie, discovery, or `app/data`.
105
+ def tenant_id
106
+ @tenant_id ||= tenant_id_without_app_data || app_data_metadata["tenant_id"]
107
+ end
108
+
109
+ # The school year id sent as `X-Webuntis-Api-School-Year-Id`, if any.
110
+ def school_year_id
111
+ Thread.current[school_year_key] || @school_year_id
112
+ end
113
+
114
+ # Runs the block with every REST call scoped to one school year (thread-locally).
115
+ def with_school_year(id)
116
+ previous = Thread.current[school_year_key]
117
+ Thread.current[school_year_key] = id
118
+ yield self
119
+ ensure
120
+ Thread.current[school_year_key] = previous
121
+ end
122
+
123
+ # The current JWT, minted lazily via `api/token/new` and refreshed before it expires.
124
+ def token
125
+ ensure_login!
126
+ return @session.token unless @session.token_stale?(now)
127
+
128
+ mint_token!(strict: false)
129
+ end
130
+
131
+ # Teachers from `getTeachers`, indexed by id and memoised for this session.
132
+ def teachers_by_id
133
+ @teachers_by_id ||= rpc.teachers.to_h { |teacher| [teacher["id"], teacher] }
134
+ end
135
+
136
+ # Class teachers per class: `{"class" => {id, name, long_name}, "teachers" => [{id, name, long_name, fore_name}]}`.
137
+ # `source: :rest` reads `timetable/filter?resourceType=CLASS` (`classTeacher1`/`classTeacher2`) for `date`;
138
+ # `source: :rpc` joins `getKlassen`'s `teacher1`/`teacher2` against `getTeachers` and also yields forenames.
139
+ def class_teachers(source: :rest, date: nil, school_year_id: nil, include_inactive: false)
140
+ case source
141
+ when :rest then rest_class_teachers(date || Date.today)
142
+ when :rpc then rpc_class_teachers(school_year_id, include_inactive)
143
+ else raise ArgumentError, "source must be :rest or :rpc, got #{source.inspect}"
144
+ end
145
+ end
146
+
147
+ # Performs one JSON-RPC call, re-authenticating once when the session expired.
148
+ def rpc_call(method, params = {})
149
+ ensure_login!
150
+ payload = rpc_post(method, params)
151
+ if not_authenticated?(payload)
152
+ login!
153
+ payload = rpc_post(method, params)
154
+ end
155
+ raise rpc_error(method, payload["error"]) if payload["error"]
156
+
157
+ payload["result"]
158
+ end
159
+
160
+ # Performs one REST call, re-authenticating once when the session is rejected: a 401/403, a redirect
161
+ # to `index.do`, or a failed call for which no JWT could be minted.
162
+ def rest_request(method, path, query: {}, body: nil)
163
+ ensure_login!
164
+ response = perform_rest(method, path, query, body)
165
+ if auth_rejected?(response) || sent_without_token?(response)
166
+ login!
167
+ response = perform_rest(method, path, query, body)
168
+ end
169
+ handle_rest(method, path, response)
170
+ end
171
+
172
+ def inspect
173
+ "#<WebUntis::Client school=#{@school_query.inspect} login=#{@login_mode.inspect} logged_in=#{@logged_in}>"
174
+ end
175
+
176
+ private
177
+
178
+ def now
179
+ @now.call
180
+ end
181
+
182
+ def school_year_key
183
+ @school_year_key ||= :"webuntis_school_year_#{object_id}"
184
+ end
185
+
186
+ def derive_login_mode
187
+ return :secret if @secret
188
+ return :anonymous if @anonymous
189
+
190
+ :rpc
191
+ end
192
+
193
+ def validate_login_mode(mode)
194
+ unless LOGIN_MODES.include?(mode)
195
+ raise ArgumentError, "login must be one of #{LOGIN_MODES.inspect}, got #{mode.inspect}"
196
+ end
197
+
198
+ missing = case mode
199
+ when :rpc, :form then { username: @username, password: @password }
200
+ when :secret then { username: @username, secret: @secret }
201
+ else {}
202
+ end.filter_map { |name, value| name if value.nil? || value.to_s.empty? }
203
+ raise ArgumentError, "login mode #{mode.inspect} requires #{missing.join(" and ")}" unless missing.empty?
204
+
205
+ mode
206
+ end
207
+
208
+ def explicit_school
209
+ School.new(
210
+ server: @server,
211
+ login_name: @school_query,
212
+ display_name: @school_query,
213
+ tenant_id: @configured_tenant_id
214
+ )
215
+ end
216
+
217
+ def reset_state!
218
+ @session = Session.new
219
+ @cookies = CookieJar.new
220
+ # The server expects the school name base64-encoded *and* prefixed with an underscore.
221
+ @cookies["schoolname"] = "_#{Util.base64(school.name)}"
222
+ @teachers_by_id = nil
223
+ @app_data = nil
224
+ @tenant_id = nil
225
+ @logged_in = false
226
+ end
227
+
228
+ def tenant_id_without_app_data
229
+ @configured_tenant_id || @cookies["Tenant-Id"] || school.tenant_id
230
+ end
231
+
232
+ def app_data_metadata
233
+ return @app_data if @app_data
234
+
235
+ # Set before the request: fetch_app_data builds its headers via tenant_id, which lands here again.
236
+ @app_data = {}
237
+ payload = fetch_app_data
238
+ @app_data = {
239
+ "tenant_id" => payload.dig("tenant", "id"),
240
+ "school_year_id" => payload.dig("currentSchoolYear", "id")
241
+ }
242
+ @session.tenant_id ||= @app_data["tenant_id"]
243
+ @session.school_year_id ||= @app_data["school_year_id"]
244
+ @app_data
245
+ end
246
+
247
+ def fetch_app_data
248
+ response = http_call(:get, "#{base_url}/#{APP_DATA_PATH}", headers: rest_headers)
249
+ @cookies.absorb(response)
250
+ payload = response.success? ? response.json : nil
251
+ payload.is_a?(Hash) ? payload : {}
252
+ end
253
+
254
+ def login_rpc!
255
+ payload = rpc_post("authenticate",
256
+ { "user" => @username, "password" => @password, "client" => @client_name })
257
+ raise auth_error_from_rpc(payload["error"]) if payload["error"]
258
+
259
+ result = payload["result"]
260
+ unless result.is_a?(Hash) && result["sessionId"]
261
+ code = result.is_a?(Hash) ? result["code"] : nil
262
+ raise AuthError.new("login failed#{" with code #{code}" if code}", stage: :login)
263
+ end
264
+ store_rpc_session(result)
265
+ end
266
+
267
+ def store_rpc_session(result)
268
+ @session.session_id = result["sessionId"]
269
+ @session.person_id = result["personId"]
270
+ @session.person_type = result["personType"]
271
+ @session.klasse_id = result["klasseId"]
272
+ @cookies["JSESSIONID"] = result["sessionId"]
273
+ end
274
+
275
+ def login_form!
276
+ seed = http_call(:get, "#{base_url}/index.do", headers: { "Accept" => "text/html, */*" })
277
+ @cookies.absorb(seed)
278
+ response = http_call(
279
+ :post, "#{base_url}/j_spring_security_check",
280
+ headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "text/html, */*" },
281
+ body: URI.encode_www_form(school: school.name, j_username: @username, j_password: @password)
282
+ )
283
+ @cookies.absorb(response)
284
+ # A form login answers 302 whether or not it worked; only a mintable JWT proves it did.
285
+ mint_token!
286
+ @session.session_id = @cookies["JSESSIONID"]
287
+ end
288
+
289
+ def login_otp!(otp, user)
290
+ url = "#{base_url}/jsonrpc_intern.do?" \
291
+ "#{URI.encode_www_form(m: "getUserData2017", school: school.name, v: "i2.2")}"
292
+ response = http_call(
293
+ :post, url,
294
+ headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
295
+ body: JSON.generate(
296
+ "id" => SecureRandom.hex(8),
297
+ "method" => "getUserData2017",
298
+ "params" => [{ "auth" => { "clientTime" => (now.to_f * 1000).round, "user" => user, "otp" => otp } }],
299
+ "jsonrpc" => "2.0"
300
+ )
301
+ )
302
+ finish_otp_login(response, user)
303
+ end
304
+
305
+ def finish_otp_login(response, user)
306
+ unless response.success?
307
+ raise AuthError.new("OTP login failed (HTTP #{response.status})", stage: :login, status: response.status)
308
+ end
309
+
310
+ payload = response.json
311
+ raise auth_error_from_rpc(payload["error"]) if payload.is_a?(Hash) && payload["error"]
312
+
313
+ @cookies.absorb(response)
314
+ session_id = @cookies["JSESSIONID"]
315
+ raise AuthError.new("login returned no JSESSIONID cookie", stage: :login) if session_id.nil?
316
+
317
+ @session.session_id = session_id
318
+ load_person_from_app_config unless user == ANONYMOUS_USER
319
+ end
320
+
321
+ def load_person_from_app_config
322
+ response = http_call(:get, "#{base_url}/api/app/config", headers: { "Accept" => "application/json",
323
+ "Cookie" => cookie_header })
324
+ @cookies.absorb(response)
325
+ payload = response.json
326
+ user = payload.is_a?(Hash) ? payload.dig("data", "loginServiceConfig", "user") : nil
327
+ raise AuthError.new("app config did not contain a login service user", stage: :login) unless user.is_a?(Hash)
328
+
329
+ @session.person_id = user["personId"]
330
+ person = Array(user["persons"]).find { |entry| entry["id"] == user["personId"] }
331
+ @session.person_type = person && person["type"]
332
+ end
333
+
334
+ def mint_token!(strict: true)
335
+ response = http_call(:get, "#{base_url}/api/token/new",
336
+ headers: { "Accept" => "text/plain, */*", "Cookie" => cookie_header }.compact)
337
+ @cookies.absorb(response)
338
+ value = response.body.to_s.strip
339
+ return store_token(value) if response.success? && !value.empty? && !html?(value)
340
+ if strict
341
+ raise AuthError.new("could not mint a JWT (HTTP #{response.status})", stage: :token,
342
+ status: response.status)
343
+ end
344
+
345
+ @session.forget_token
346
+ nil
347
+ end
348
+
349
+ def store_token(value)
350
+ expiry = jwt_expiry(value)
351
+ @session.token = value
352
+ if expiry
353
+ @session.token_expires_at = Time.at(expiry)
354
+ @session.stale_at = Time.at(expiry - TOKEN_SKEW)
355
+ else
356
+ @session.token_expires_at = now + TOKEN_FALLBACK_TTL
357
+ @session.stale_at = @session.token_expires_at
358
+ end
359
+ value
360
+ end
361
+
362
+ def jwt_expiry(token)
363
+ segment = token.split(".")[1]
364
+ return nil if segment.nil?
365
+
366
+ claims = JSON.parse(Util.base64url_decode(segment))
367
+ claims.is_a?(Hash) && claims["exp"].is_a?(Numeric) ? claims["exp"] : nil
368
+ rescue JSON::ParserError, ArgumentError
369
+ nil
370
+ end
371
+
372
+ def html?(value)
373
+ value.match?(/\A\s*<(!doctype|html)/i)
374
+ end
375
+
376
+ def cookie_header
377
+ @cookies.header
378
+ end
379
+
380
+ # Headers every modern REST call carries.
381
+ def rest_headers(json_body: false)
382
+ jwt = token
383
+ {
384
+ "Accept" => "application/json, text/plain, */*",
385
+ "Authorization" => jwt && "Bearer #{jwt}",
386
+ "Tenant-Id" => tenant_id&.to_s,
387
+ "X-Webuntis-Api-School-Year-Id" => school_year_id&.to_s,
388
+ "Cookie" => cookie_header,
389
+ "Content-Type" => json_body ? "application/json" : nil
390
+ }.compact
391
+ end
392
+
393
+ def perform_rest(method, path, query, body)
394
+ payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
395
+ http_call(method, rest_url(path, query),
396
+ headers: rest_headers(json_body: !payload.nil?), body: payload)
397
+ end
398
+
399
+ def rest_url(path, query)
400
+ url = "#{base_url}/#{path.to_s.sub(%r{\A/+}, "")}"
401
+ encoded = REST.encode_query(query)
402
+ encoded.empty? ? url : "#{url}?#{encoded}"
403
+ end
404
+
405
+ def handle_rest(method, path, response)
406
+ @cookies.absorb(response)
407
+ unless response.success?
408
+ raise HttpError.new("HTTP #{response.status} for #{method.to_s.upcase} #{path}",
409
+ status: response.status, body: response.body, method: method, path: path)
410
+ end
411
+
412
+ payload = response.json
413
+ check_api_error(payload, path)
414
+ payload
415
+ end
416
+
417
+ def check_api_error(payload, path)
418
+ return unless payload.is_a?(Hash)
419
+
420
+ key = payload.dig("data", "error", "data", "messageKey")
421
+ raise ApiError.new(key.to_s, message_key: key, body: payload, path: path) if key
422
+
423
+ message = payload["errorMessage"]
424
+ raise ApiError.new(message.to_s, body: payload, path: path) if message
425
+ end
426
+
427
+ def auth_rejected?(response)
428
+ return true if [401, 403].include?(response.status)
429
+
430
+ response.redirect? && response.location.to_s.include?("index.do")
431
+ end
432
+
433
+ # `api/token/new` redirects instead of minting once the session is dead, and the view API answers
434
+ # a request without a bearer token with 404 rather than 401.
435
+ def sent_without_token?(response)
436
+ !response.success? && @session.token.nil?
437
+ end
438
+
439
+ def rpc_url
440
+ "#{base_url}/jsonrpc.do?#{URI.encode_www_form(school: school.name)}"
441
+ end
442
+
443
+ def rpc_post(method, params)
444
+ body = JSON.generate(
445
+ "id" => SecureRandom.hex(8),
446
+ "method" => method,
447
+ "params" => Util.compact_params(params),
448
+ "jsonrpc" => "2.0"
449
+ )
450
+ response = http_call(:post, rpc_url, headers: rpc_headers, body: body)
451
+ @cookies.absorb(response)
452
+ unless response.success?
453
+ raise HttpError.new("HTTP #{response.status} for JSON-RPC #{method}", status: response.status,
454
+ body: response.body, method: :post,
455
+ path: "jsonrpc.do")
456
+ end
457
+
458
+ parse_rpc_body(response, method)
459
+ end
460
+
461
+ def parse_rpc_body(response, method)
462
+ payload = response.json
463
+ raise Error, "JSON-RPC #{method} returned a non-JSON body" unless payload.is_a?(Hash)
464
+
465
+ payload
466
+ end
467
+
468
+ def rpc_headers
469
+ { "Content-Type" => "application/json", "Accept" => "application/json", "Cookie" => cookie_header }.compact
470
+ end
471
+
472
+ def not_authenticated?(payload)
473
+ error = payload["error"]
474
+ return false unless error.is_a?(Hash)
475
+
476
+ error["code"] == -8520 || error["message"].to_s.downcase.include?("not authenticated")
477
+ end
478
+
479
+ def rpc_error(method, error)
480
+ error = {} unless error.is_a?(Hash)
481
+ RpcError.new("JSON-RPC #{method} failed: #{error["message"]}",
482
+ code: error["code"], data: error["data"], method: method)
483
+ end
484
+
485
+ def auth_error_from_rpc(error)
486
+ error = {} unless error.is_a?(Hash)
487
+ AuthError.new("login failed: #{error["message"]}#{" (code #{error["code"]})" if error["code"]}",
488
+ stage: :login)
489
+ end
490
+
491
+ def rest_class_teachers(date)
492
+ filter = rest.timetable_filter(resource_type: :class, start: date, end: date)
493
+ Array(filter["classes"]).map do |entry|
494
+ teachers = [entry["classTeacher1"], entry["classTeacher2"]].compact
495
+ {
496
+ "class" => resource_row(entry["class"]),
497
+ "teachers" => teachers.map { |teacher| resource_row(teacher).merge("fore_name" => nil) }
498
+ }
499
+ end
500
+ end
501
+
502
+ def resource_row(resource)
503
+ { "id" => resource["id"], "name" => resource["shortName"], "long_name" => resource["longName"] }
504
+ end
505
+
506
+ def rpc_class_teachers(school_year_id, include_inactive)
507
+ klassen = Array(rpc.classes(school_year_id: school_year_id))
508
+ unless klassen.any? { |klasse| klasse.key?("teacher1") || klasse.key?("teacher2") }
509
+ raise Error, "this account cannot see class teachers via getKlassen: no teacher1/teacher2 fields were returned"
510
+ end
511
+
512
+ klassen.filter_map do |klasse|
513
+ next if !include_inactive && klasse["active"] == false
514
+
515
+ {
516
+ "class" => { "id" => klasse["id"], "name" => klasse["name"], "long_name" => klasse["longName"] },
517
+ "teachers" => rpc_class_teachers_for(klasse)
518
+ }
519
+ end
520
+ end
521
+
522
+ def rpc_class_teachers_for(klasse)
523
+ [klasse["teacher1"], klasse["teacher2"]].filter_map do |id|
524
+ next if id.to_i.zero?
525
+
526
+ teacher = teachers_by_id[id]
527
+ next unless teacher
528
+
529
+ { "id" => teacher["id"], "name" => teacher["name"], "long_name" => teacher["longName"],
530
+ "fore_name" => teacher["foreName"] }
531
+ end
532
+ end
533
+
534
+ def http_call(method, url, headers: {}, body: nil)
535
+ response = @http.call(method, url, headers: headers, body: body)
536
+ @logger&.debug { "WebUntis #{method.to_s.upcase} #{URI.parse(url).path} -> #{response.status}" }
537
+ response
538
+ end
539
+ end
540
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebUntis
4
+ # A name-keyed cookie store; WebUntis only ever needs the newest value per name.
5
+ class CookieJar
6
+ def initialize
7
+ @cookies = {}
8
+ end
9
+
10
+ # Records the `Set-Cookie` values of one response.
11
+ def absorb(response)
12
+ response.set_cookies.each do |raw|
13
+ pair = raw.to_s.split(";", 2).first.to_s
14
+ name, value = pair.split("=", 2)
15
+ next if value.nil? || name.strip.empty?
16
+
17
+ self[name.strip] = value.strip
18
+ end
19
+ self
20
+ end
21
+
22
+ # The value of one cookie, without the double quotes a server may wrap it in (RFC 6265).
23
+ def [](name)
24
+ value = @cookies[name.to_s]
25
+ value&.match?(/\A".*"\z/) ? value[1..-2] : value
26
+ end
27
+
28
+ # Sets one cookie value; a nil value removes it.
29
+ def []=(name, value)
30
+ if value.nil?
31
+ @cookies.delete(name.to_s)
32
+ else
33
+ @cookies[name.to_s] = value.to_s
34
+ end
35
+ end
36
+
37
+ # The `Cookie` request header value, or nil when the jar is empty.
38
+ def header
39
+ return nil if @cookies.empty?
40
+
41
+ @cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
42
+ end
43
+
44
+ # Drops every cookie.
45
+ def clear
46
+ @cookies.clear
47
+ self
48
+ end
49
+
50
+ # True when the jar holds no cookies.
51
+ def empty?
52
+ @cookies.empty?
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebUntis
4
+ # The numeric element types WebUntis uses for classes, people, subjects and rooms.
5
+ module ElementType
6
+ CLASS = 1
7
+ TEACHER = 2
8
+ SUBJECT = 3
9
+ ROOM = 4
10
+ STUDENT = 5
11
+
12
+ NAMES = {
13
+ "class" => CLASS,
14
+ "klasse" => CLASS,
15
+ "teacher" => TEACHER,
16
+ "subject" => SUBJECT,
17
+ "room" => ROOM,
18
+ "student" => STUDENT
19
+ }.freeze
20
+
21
+ LABELS = {
22
+ CLASS => "CLASS",
23
+ TEACHER => "TEACHER",
24
+ SUBJECT => "SUBJECT",
25
+ ROOM => "ROOM",
26
+ STUDENT => "STUDENT"
27
+ }.freeze
28
+
29
+ # Coerces an Integer, Symbol or String element type to its numeric form.
30
+ def self.resolve(value)
31
+ return value if value.is_a?(Integer)
32
+ return nil if value.nil?
33
+
34
+ key = value.to_s.downcase
35
+ NAMES[key] || (raise Error, "unknown element type #{value.inspect}")
36
+ end
37
+
38
+ # Coerces an element type to the uppercase name the modern REST API expects.
39
+ def self.label(value)
40
+ return nil if value.nil?
41
+ return LABELS.fetch(value) { raise Error, "unknown element type #{value.inspect}" } if value.is_a?(Integer)
42
+
43
+ name = value.to_s.upcase
44
+ return name if LABELS.value?(name)
45
+
46
+ LABELS.fetch(resolve(value))
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebUntis
4
+ class Error < StandardError; end
5
+
6
+ # Raised when a school cannot be resolved to exactly one WebUntis tenant.
7
+ class DiscoveryError < Error
8
+ attr_reader :query, :matches
9
+
10
+ def initialize(message, query: nil, matches: [])
11
+ super(message)
12
+ @query = query
13
+ @matches = matches
14
+ end
15
+ end
16
+
17
+ # Raised when logging in, minting a JWT, or re-authenticating fails.
18
+ class AuthError < Error
19
+ attr_reader :stage, :status
20
+
21
+ def initialize(message, stage: nil, status: nil)
22
+ super(message)
23
+ @stage = stage
24
+ @status = status
25
+ end
26
+ end
27
+
28
+ # Raised when a JSON-RPC response carries an `error` member.
29
+ class RpcError < Error
30
+ attr_reader :code, :data, :method
31
+
32
+ def initialize(message, code: nil, data: nil, method: nil)
33
+ super(message)
34
+ @code = code
35
+ @data = data
36
+ @method = method
37
+ end
38
+ end
39
+
40
+ # Raised for any non-success HTTP status the client did not handle itself.
41
+ class HttpError < Error
42
+ attr_reader :status, :body, :method, :path
43
+
44
+ def initialize(message, status: nil, body: nil, method: nil, path: nil)
45
+ super(message)
46
+ @status = status
47
+ @body = body
48
+ @method = method
49
+ @path = path
50
+ end
51
+ end
52
+
53
+ # Raised when a 2xx REST body carries an application-level error envelope.
54
+ class ApiError < Error
55
+ attr_reader :message_key, :body, :path
56
+
57
+ def initialize(message, message_key: nil, body: nil, path: nil)
58
+ super(message)
59
+ @message_key = message_key
60
+ @body = body
61
+ @path = path
62
+ end
63
+ end
64
+ end