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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1dcd96e7abfbd4f863336185dfbedae0aceaf43689e1ba9a8efd9f9b527fd017
4
+ data.tar.gz: 5bceaf819d35ade46f500e5f0dc1c4db7a07050e3fc6c6e91f1f9c4c7ae6bd98
5
+ SHA512:
6
+ metadata.gz: d24bda2f99e561945d6ab560fb6b9f3df2a16098d4b838035b2d31742e31f6f90528d3684efc14a7a6c014c3ffd22d2126ecb08efae3edcc647a05500ec33362
7
+ data.tar.gz: 33a36b3307c68b7969b750c5869ef75c88b75b953c4a603eddba20c4f64aab78152091563b54037145342711affedf8cb68809e1ae0e01111e91f0a326ca688a
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ **Initial Release**
6
+
7
+ - REST calls re-authenticate when `api/token/new` refuses to mint a JWT and the call then
8
+ fails; the view API answers token-less requests with 404, which previously left the
9
+ client stuck on a dead session.
10
+ - `rest.calendar_entry_detail` sends the numeric `elementType` the endpoint expects.
11
+ - `rest.student_absences` defaults `student_id` to `-1` (all students); the endpoint
12
+ answers 500 without it.
13
+ - `rpc.exams` defaults `exam_type_id` to `0` (every type); `getExams` rejects the call
14
+ without `examTypeId`.
15
+
16
+ - `WebUntis::School.search` / `.find` for tenant discovery via `schoolquery2`.
17
+ - `WebUntis::Client` with four login flows (`:rpc`, `:form`, `:secret`, `:anonymous`),
18
+ a cookie jar, lazy JWT minting from `api/token/new`, tenant-id resolution and
19
+ re-authenticate-once-and-retry on expired sessions.
20
+ - `client.rpc`: the documented JSON-RPC API with snake_case parameters.
21
+ - `client.rest`: the modern `api/rest/view/v1|v2|v3|v4` endpoints the web app uses,
22
+ plus the older cookie-only `api/...` endpoints; `timetable_entries` reads the tenant's
23
+ default format from `timetable/grid`.
24
+ - `client.class_teachers` from `timetable/filter` (default) or by joining `getKlassen`
25
+ to `getTeachers` (`source: :rpc`).
26
+ - `WebUntis::HTTP::NetHttp` and an injectable `WebUntis::HTTP::Fake` adapter.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meow the Cat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # webuntis-api
2
+
3
+ A dependency-free Ruby client for **WebUntis**, covering both API surfaces a school
4
+ account can reach:
5
+
6
+ - the documented **JSON-RPC** API at `/WebUntis/jsonrpc.do`, and
7
+ - the **modern REST** API at `/WebUntis/api/rest/view/...` that the WebUntis web app
8
+ itself calls (plus the older cookie-only `/WebUntis/api/...` endpoints).
9
+
10
+ Zero runtime dependencies: only `net/http`, `uri`, `json`, `securerandom`, `date`,
11
+ `time` and `openssl` from the standard library. Ruby >= 3.2.
12
+
13
+ This is the successor to the author's older `untis` gem (`UntisWorker`, JSON-RPC only,
14
+ raw hashes and error hashes). The big behavioural change: **this gem raises instead of
15
+ returning error hashes** - see [Errors](#errors).
16
+
17
+ ## Install
18
+
19
+ ```ruby
20
+ gem "webuntis-api"
21
+ ```
22
+
23
+ ```sh
24
+ gem install webuntis-api
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```ruby
30
+ require "webuntis"
31
+
32
+ client = WebUntis::Client.new(
33
+ school: "heidelberg-college", # login name (preferred) or display name
34
+ username: ENV.fetch("WEBUNTIS_USER"),
35
+ password: ENV.fetch("WEBUNTIS_PASSWORD")
36
+ # server: "heidelberg-college.webuntis.com" # skip the discovery round-trip
37
+ )
38
+
39
+ client.rpc.classes.each { |klasse| puts klasse["name"] }
40
+ client.logout!
41
+ ```
42
+
43
+ The client logs in lazily on the first call that needs a session, so `login!` is
44
+ optional. `client.session` exposes `session_id`, `person_id`, `person_type`,
45
+ `klasse_id`, `tenant_id`, `token`, `token_expires_at` and `school_year_id`.
46
+
47
+ A client is not thread-safe: a re-login swaps the session and cookie jar underneath
48
+ concurrent calls. Use one client per thread.
49
+
50
+ ### Finding a school
51
+
52
+ ```ruby
53
+ WebUntis::School.search("Heidelberg")
54
+ # => [#<WebUntis::School "Heidelberg College" login_name="heidelberg-college" ...>]
55
+
56
+ school = WebUntis::School.find("Heidelberg College")
57
+ school.login_name # => "heidelberg-college"
58
+ school.server # => "heidelberg-college.webuntis.com"
59
+ school.tenant_id # => "4239400"
60
+ school.base_url # => "https://heidelberg-college.webuntis.com/WebUntis"
61
+ ```
62
+
63
+ `find` raises `WebUntis::DiscoveryError` when nothing or more than one tenant matches;
64
+ an exact case-insensitive hit on the display name, login name or server wins over
65
+ partial matches.
66
+
67
+ ### Login flows
68
+
69
+ | `login:` | Credentials | Endpoint |
70
+ | --- | --- | --- |
71
+ | `:rpc` (default) | `username:` + `password:` | `jsonrpc.do` `authenticate` |
72
+ | `:form` | `username:` + `password:` | `j_spring_security_check`, verified by `api/token/new` |
73
+ | `:secret` | `username:` + `secret:` | TOTP via `jsonrpc_intern.do?m=getUserData2017` |
74
+ | `:anonymous` | `anonymous: true` | the same OTP flow with user `#anonymous#` |
75
+
76
+ The mode is derived from the credentials you pass, so `secret:` implies `:secret` and
77
+ `anonymous: true` implies `:anonymous`; pass `login:` to override. The constructor
78
+ raises `ArgumentError` when the mode is unknown or its credentials are missing.
79
+
80
+ ## The modern REST layer
81
+
82
+ `client.rest` sends `Authorization: Bearer <jwt>` (minted lazily from `api/token/new`
83
+ and refreshed 30 s before the JWT's `exp`), `Tenant-Id`, the session cookies and
84
+ `Accept: application/json, text/plain, */*`. Results are returned exactly as the API
85
+ sends them (Hashes/Arrays with String keys).
86
+
87
+ ```ruby
88
+ # 1. What the SPA loads first: tenant, permissions, current school year.
89
+ data = client.rest.app_data
90
+ data.dig("currentSchoolYear", "id") # => 17
91
+
92
+ # 2. One class's timetable for a week.
93
+ entries = client.rest.timetable_entries(
94
+ resource_type: :class, # "CLASS" / :class / WebUntis::ElementType::CLASS
95
+ resources: [470],
96
+ start: Date.new(2026, 3, 23),
97
+ end: Date.new(2026, 3, 27)
98
+ )
99
+ # GET api/rest/view/v1/timetable/entries?start=2026-03-23&end=2026-03-27&format=3
100
+ # &resourceType=CLASS&resources=470&periodTypes=&timetableType=STANDARD&layout=START_TIME
101
+ # `format` defaults to the tenant's format for that resource type (`classFormat` etc.),
102
+ # read once from api/rest/view/v1/timetable/grid; pass `format:` to override.
103
+
104
+ # 3. The inbox.
105
+ client.rest.messages["incomingMessages"]
106
+ ```
107
+
108
+ Anything not wrapped is one line away:
109
+
110
+ ```ruby
111
+ client.rest.get("api/rest/view/v1/timetable/entries/settings", { "format" => 2, "resourceType" => "CLASS" })
112
+ client.rest.post("api/rest/view/v1/classreg/homework/list", { "dateRange" => { "start" => "2026-03-23", "end" => "2026-03-27" } })
113
+ ```
114
+
115
+ Query values are encoded for you: Arrays join with `,`, `Date`/`Time` become
116
+ `YYYY-MM-DD`, booleans become `true`/`false`, and `nil` values are dropped.
117
+
118
+ Some endpoints are scoped to a school year; wrap those calls to send
119
+ `X-Webuntis-Api-School-Year-Id`:
120
+
121
+ ```ruby
122
+ client.with_school_year(16) { |scoped| scoped.rest.timetable_grid }
123
+ ```
124
+
125
+ ## The JSON-RPC layer
126
+
127
+ ```ruby
128
+ client.rpc.teachers
129
+ client.rpc.classes(school_year_id: 17)
130
+ client.rpc.substitutions(start_date: Date.new(2026, 3, 23), end_date: Date.new(2026, 3, 27))
131
+
132
+ client.rpc.timetable(
133
+ element_id: 470, element_type: :class,
134
+ start_date: Date.new(2026, 3, 23), end_date: Date.new(2026, 3, 27),
135
+ show_subst_text: true, teacher_fields: %w[id name longname]
136
+ )
137
+
138
+ client.rpc.call("getAnythingElse", { "someParam" => 1 }) # generic escape hatch
139
+ ```
140
+
141
+ Parameters are written in snake_case and translated to the API's names
142
+ (`school_year_id` -> `schoolyearId`, `surname` -> `sn`); raw camelCase keys are accepted
143
+ too, and `timetable` raises `ArgumentError` for an option it does not know. Dates accept
144
+ `Date`/`Time`/`DateTime`, `"YYYYMMDD"` / `"YYYY-MM-DD"` Strings or Integers and are
145
+ sent as `YYYYMMDD` Integers; `nil` parameters are dropped. Element types accept
146
+ `:teacher`, `"TEACHER"` or `WebUntis::ElementType::TEACHER`.
147
+
148
+ Results are **not** transformed. Two helpers are there when you want to read them:
149
+
150
+ ```ruby
151
+ WebUntis::Util.parse_date(20_260_916) # => #<Date 2026-09-16>
152
+ WebUntis::Util.parse_time(915) # => [9, 15]
153
+ ```
154
+
155
+ ## Class teachers (Klassenlehrer)
156
+
157
+ ```ruby
158
+ client.class_teachers
159
+ # => [{ "class" => { "id" => 481, "name" => "10/1", "long_name" => "10/1" },
160
+ # "teachers" => [{ "id" => 187, "name" => "HOF", "long_name" => "Hoffmann", "fore_name" => nil }] }, ...]
161
+ ```
162
+
163
+ Both sources return that same shape:
164
+
165
+ - `source: :rest` (default) reads `api/rest/view/v1/timetable/filter?resourceType=CLASS`,
166
+ whose class rows carry `classTeacher1`/`classTeacher2`. The filter is scoped to one day
167
+ (`date:`, default today). Forenames are not part of that payload, so `fore_name` is nil.
168
+ - `source: :rpc` joins `getKlassen`'s `teacher1`/`teacher2` against `getTeachers`
169
+ (teacher1 first, unknown and empty ids skipped, inactive classes filtered unless
170
+ `include_inactive: true`, `school_year_id:` forwarded) and raises `WebUntis::Error`
171
+ when the account cannot see those fields at all. Anonymous sessions get 401 from the
172
+ view API, so this is their only option, and it works only where the tenant grants
173
+ public access to `getTeachers`.
174
+
175
+ `examples/class_teachers.rb` prints them one class per line:
176
+
177
+ ```sh
178
+ WEBUNTIS_SCHOOL=heidelberg-college WEBUNTIS_USER=... WEBUNTIS_PASSWORD=... \
179
+ ruby examples/class_teachers.rb
180
+ # 10/1 Hoffmann (HOF)
181
+
182
+ ruby examples/class_teachers.rb --source rpc # Hoffmann Luca (HOF)
183
+ ruby examples/class_teachers.rb --settings config/settings.local.yml --json
184
+ ```
185
+
186
+ `--settings` reads a YAML file with an `untis:` key holding `school_name`, `login` and
187
+ `password`.
188
+
189
+ ## Errors
190
+
191
+ Everything descends from `WebUntis::Error`:
192
+
193
+ | Class | Raised when |
194
+ | --- | --- |
195
+ | `DiscoveryError` | no school, or more than one, matched the query |
196
+ | `AuthError` | login failed, or a JWT could not be minted (`stage`, `status`) |
197
+ | `RpcError` | a JSON-RPC response carried an `error` member (`code`, `data`, `method`) |
198
+ | `HttpError` | an unhandled non-2xx response (`status`, `body`, `method`, `path`) |
199
+ | `ApiError` | a 2xx REST body carried `data.error.data.messageKey` or `errorMessage` |
200
+
201
+ Expired sessions are handled before they reach you: a JSON-RPC `-8520`
202
+ ("not authenticated"), a REST 401/403, a REST redirect to `index.do`, or a failed REST
203
+ call for which `api/token/new` refused to mint a JWT makes the client log in again and
204
+ retry the call **once**.
205
+
206
+ ## Testing against a fake
207
+
208
+ The HTTP layer is injectable. Any object answering
209
+ `call(method, url, headers:, body:) -> WebUntis::HTTP::Response` will do;
210
+ `WebUntis::HTTP::Fake` is included:
211
+
212
+ ```ruby
213
+ fake = WebUntis::HTTP::Fake.new
214
+ fake.stub_json({ "jsonrpc" => "2.0", "result" => { "sessionId" => "SID" } })
215
+ fake.stub_json({ "jsonrpc" => "2.0", "result" => [{ "id" => 1, "name" => "5a" }] })
216
+
217
+ client = WebUntis::Client.new(school: "demo", server: "demo.webuntis.com",
218
+ username: "u", password: "p", http: fake)
219
+ client.rpc.classes
220
+ fake.requests.last.json # => {"id"=>"...", "method"=>"getKlassen", ...}
221
+ ```
222
+
223
+ A block form answers per request: `WebUntis::HTTP::Fake.new { |request| ... }`.
224
+
225
+ ## Which API is this?
226
+
227
+ - The **JSON-RPC** API is the only officially documented one. Untis publishes it for
228
+ schools and it changes rarely.
229
+ - The `/WebUntis/api/rest/view/...` endpoints are the **web app's internal API**.
230
+ They are not documented, differ between tenants and roles, and may change without
231
+ notice - pin your gem version and expect to adapt.
232
+ - The official partner **Untis Platform API** (`api.webuntis.com`, OAuth client
233
+ credentials, requires an integration partnership) is a different product and is
234
+ **out of scope** for this gem.
235
+
236
+ Untis asks that applications log out as soon as they are done; server sessions are
237
+ otherwise dropped after roughly ten minutes of idling.
238
+
239
+ ## License
240
+
241
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "yaml"
7
+ require_relative "../lib/webuntis"
8
+
9
+ options = { json: false, settings: nil, source: :rest }
10
+ OptionParser.new do |parser|
11
+ parser.banner = "Usage: class_teachers.rb [options]"
12
+ parser.on("--settings PATH", "YAML file with an `untis:` key (school_name, login, password)") do |path|
13
+ options[:settings] = path
14
+ end
15
+ parser.on("--source SOURCE", %w[rest rpc],
16
+ "rest (default): timetable/filter classTeacher1/2; rpc: getKlassen joined with getTeachers") do |s|
17
+ options[:source] = s.to_sym
18
+ end
19
+ parser.on("--json", "Print the raw class_teachers result as JSON") { options[:json] = true }
20
+ parser.on("-h", "--help") do
21
+ puts parser
22
+ exit 0
23
+ end
24
+ end.parse!
25
+
26
+ settings = options[:settings] ? (YAML.safe_load_file(options[:settings])["untis"] || {}) : {}
27
+
28
+ school = settings["school_name"] || ENV["WEBUNTIS_SCHOOL"] || "heidelberg-college"
29
+ username = settings["login"] || ENV.fetch("WEBUNTIS_USER", nil)
30
+ password = settings["password"] || ENV.fetch("WEBUNTIS_PASSWORD", nil)
31
+ server = ENV.fetch("WEBUNTIS_SERVER", nil)
32
+
33
+ abort("set WEBUNTIS_USER and WEBUNTIS_PASSWORD, or pass --settings PATH") if username.nil? || password.nil?
34
+
35
+ def teacher_label(teacher)
36
+ name = [teacher["long_name"], teacher["fore_name"]].compact.reject(&:empty?).join(" ")
37
+ short = teacher["name"]
38
+ short.to_s.empty? ? name : "#{name} (#{short})"
39
+ end
40
+
41
+ begin
42
+ # A settings file may hold the display name ("Heidelberg College"); the API wants the login name.
43
+ if school.match?(/\s/)
44
+ resolved = WebUntis::School.find(school)
45
+ warn("resolved #{school.inspect} to #{resolved.login_name} on #{resolved.server}")
46
+ school = resolved.login_name
47
+ server ||= resolved.server
48
+ end
49
+
50
+ client = WebUntis::Client.new(school: school, server: server, username: username, password: password)
51
+ rows = client.class_teachers(source: options[:source])
52
+
53
+ if options[:json]
54
+ puts JSON.pretty_generate(rows)
55
+ else
56
+ rows.sort_by { |row| row.dig("class", "name").to_s }.each do |row|
57
+ labels = row["teachers"].map { |teacher| teacher_label(teacher) }
58
+ puts "#{row.dig("class", "name")} #{labels.empty? ? "-" : labels.join(", ")}"
59
+ end
60
+ end
61
+ rescue WebUntis::Error => e
62
+ warn(e.message)
63
+ exit 1
64
+ ensure
65
+ client&.logout!
66
+ end