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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebUntis
4
+ module HTTP
5
+ # A test adapter: replays queued stubs (or a block) and records every request in {#requests}.
6
+ class Fake
7
+ attr_reader :requests, :stubs
8
+
9
+ def initialize(stubs = [], &block)
10
+ @stubs = Array(stubs)
11
+ @block = block
12
+ @requests = []
13
+ end
14
+
15
+ # Queues one more stub: a {Response}, or a callable taking the {Request}.
16
+ def stub(response = nil, status: 200, body: "", headers: {}, &block)
17
+ @stubs << (response || block || Response.new(status: status, body: body, headers: headers))
18
+ self
19
+ end
20
+
21
+ # Queues a JSON response stub.
22
+ def stub_json(payload, status: 200, headers: {})
23
+ stub(Response.new(status: status, body: JSON.generate(payload),
24
+ headers: { "content-type" => "application/json" }.merge(headers)))
25
+ end
26
+
27
+ # Records the request and answers it from the block or the next queued stub.
28
+ def call(method, url, headers: {}, body: nil)
29
+ request = Request.new(method: method, url: url, headers: headers, body: body)
30
+ @requests << request
31
+ return @block.call(request) if @block
32
+
33
+ stub = @stubs.shift
34
+ raise Error, "WebUntis::HTTP::Fake ran out of stubs for #{method.to_s.upcase} #{url}" if stub.nil?
35
+
36
+ stub.respond_to?(:call) ? stub.call(request) : stub
37
+ end
38
+
39
+ # The last recorded request.
40
+ def last_request
41
+ @requests.last
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module WebUntis
7
+ module HTTP
8
+ # The default adapter: one persistent `Net::HTTP` connection per host, TLS on, no redirect following.
9
+ class NetHttp
10
+ REQUEST_CLASSES = {
11
+ get: Net::HTTP::Get,
12
+ post: Net::HTTP::Post,
13
+ put: Net::HTTP::Put,
14
+ delete: Net::HTTP::Delete
15
+ }.freeze
16
+
17
+ def initialize(open_timeout: 10, read_timeout: 30)
18
+ @open_timeout = open_timeout
19
+ @read_timeout = read_timeout
20
+ @connections = {}
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # Performs one request and returns a {Response}; 3xx responses are returned, never followed.
25
+ def call(method, url, headers: {}, body: nil)
26
+ uri = URI.parse(url)
27
+ request = build_request(method, uri, headers, body)
28
+ response = @mutex.synchronize { perform(uri, request) }
29
+ Response.new(status: response.code, body: response.body.to_s, headers: response_headers(response))
30
+ end
31
+
32
+ # Closes every pooled connection.
33
+ def close
34
+ @mutex.synchronize do
35
+ @connections.each_value { |connection| connection.finish if connection.started? }
36
+ @connections.clear
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # A pooled connection the server closed in the meantime fails once; reconnect and retry.
43
+ def perform(uri, request, retried: false)
44
+ connection_for(uri).request(request)
45
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE
46
+ raise if retried
47
+
48
+ drop_connection(uri)
49
+ perform(uri, request, retried: true)
50
+ end
51
+
52
+ def drop_connection(uri)
53
+ connection = @connections.delete(connection_key(uri))
54
+ connection.finish if connection&.started?
55
+ rescue IOError
56
+ nil
57
+ end
58
+
59
+ def connection_key(uri)
60
+ [uri.scheme, uri.host, uri.port]
61
+ end
62
+
63
+ def build_request(method, uri, headers, body)
64
+ klass = REQUEST_CLASSES.fetch(method.to_s.downcase.to_sym) { raise Error, "unsupported method #{method}" }
65
+ request = klass.new(uri.request_uri)
66
+ headers.each { |key, value| request[key.to_s] = value.to_s unless value.nil? }
67
+ request.body = body if body
68
+ request
69
+ end
70
+
71
+ def connection_for(uri)
72
+ key = connection_key(uri)
73
+ connection = @connections[key]
74
+ return connection if connection&.started?
75
+
76
+ connection = Net::HTTP.new(uri.host, uri.port)
77
+ connection.use_ssl = uri.scheme == "https"
78
+ connection.open_timeout = @open_timeout
79
+ connection.read_timeout = @read_timeout
80
+ connection.start
81
+ @connections[key] = connection
82
+ end
83
+
84
+ def response_headers(response)
85
+ response.each_header.with_object({}) do |(key, _), memo|
86
+ name = key.downcase
87
+ fields = response.get_fields(key) || []
88
+ memo[name] = name == "set-cookie" ? fields : fields.join(", ")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module WebUntis
7
+ # Transport plumbing: an adapter is anything answering
8
+ # `call(method, url, headers:, body:)` with a {Response}.
9
+ module HTTP
10
+ # One HTTP response, as every adapter must return it.
11
+ class Response
12
+ attr_reader :status, :headers, :body
13
+
14
+ def initialize(status:, body: "", headers: {})
15
+ @status = status.to_i
16
+ @body = body.to_s
17
+ @headers = headers.each_with_object({}) { |(key, value), memo| memo[key.to_s.downcase] = value }
18
+ end
19
+
20
+ # All `Set-Cookie` values of this response.
21
+ def set_cookies
22
+ Array(@headers["set-cookie"])
23
+ end
24
+
25
+ # The `Location` header, if any.
26
+ def location
27
+ value = @headers["location"]
28
+ value.is_a?(Array) ? value.first : value
29
+ end
30
+
31
+ # True for 2xx responses.
32
+ def success?
33
+ (200..299).cover?(@status)
34
+ end
35
+
36
+ # True for 3xx responses.
37
+ def redirect?
38
+ (300..399).cover?(@status)
39
+ end
40
+
41
+ # The response body parsed as JSON, or the raw String when it is not JSON.
42
+ def json
43
+ return @body if @body.strip.empty?
44
+
45
+ JSON.parse(@body)
46
+ rescue JSON::ParserError
47
+ @body
48
+ end
49
+ end
50
+
51
+ # One recorded request, as {Fake} exposes it.
52
+ class Request
53
+ attr_reader :method, :url, :headers, :body
54
+
55
+ def initialize(method:, url:, headers: {}, body: nil)
56
+ @method = method
57
+ @url = url
58
+ @headers = headers
59
+ @body = body
60
+ end
61
+
62
+ # The query string of the request URL as a Hash of String pairs.
63
+ def query
64
+ URI.decode_www_form(URI.parse(@url).query.to_s).to_h
65
+ end
66
+
67
+ # The request body parsed as JSON.
68
+ def json
69
+ JSON.parse(@body.to_s)
70
+ end
71
+
72
+ # The path of the request URL.
73
+ def path
74
+ URI.parse(@url).path
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ require_relative "http/net_http"
81
+ require_relative "http/fake"
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module WebUntis
8
+ # The modern REST API the WebUntis web app uses (`/WebUntis/api/rest/view/...`)
9
+ # plus the older `/WebUntis/api/...` endpoints that only need session cookies.
10
+ #
11
+ # Paths are relative to `<base>/`; every method returns parsed JSON with String keys.
12
+ class REST
13
+ VIEW = "api/rest/view"
14
+ RANGE_KEYS = %i[start end start_date end_date].freeze
15
+ FORMAT_KEYS = {
16
+ "CLASS" => "classFormat",
17
+ "TEACHER" => "teacherFormat",
18
+ "SUBJECT" => "subjectFormat",
19
+ "ROOM" => "roomFormat",
20
+ "STUDENT" => "studentFormat"
21
+ }.freeze
22
+
23
+ class << self
24
+ # Builds a query string: arrays join with `,`, dates become `YYYY-MM-DD`, nils are dropped.
25
+ def encode_query(query)
26
+ pairs = (query || {}).filter_map do |key, value|
27
+ next if value.nil?
28
+
29
+ [key.to_s, format_value(value)]
30
+ end
31
+ URI.encode_www_form(pairs)
32
+ end
33
+
34
+ # Formats one query value.
35
+ def format_value(value)
36
+ case value
37
+ when Array then value.map { |entry| format_value(entry) }.join(",")
38
+ when Date, Time then value.strftime("%Y-%m-%d")
39
+ else value.to_s
40
+ end
41
+ end
42
+ end
43
+
44
+ def initialize(client)
45
+ @client = client
46
+ end
47
+
48
+ # GET any path relative to `<base>/`.
49
+ def get(path, query = {})
50
+ @client.rest_request(:get, path, query: query)
51
+ end
52
+
53
+ # POST any path relative to `<base>/`, with a JSON body.
54
+ def post(path, body = nil, query: {})
55
+ @client.rest_request(:post, path, query: query, body: body)
56
+ end
57
+
58
+ # PUT any path relative to `<base>/`, with a JSON body.
59
+ def put(path, body = nil, query: {})
60
+ @client.rest_request(:put, path, query: query, body: body)
61
+ end
62
+
63
+ # DELETE any path relative to `<base>/`.
64
+ def delete(path, query = {})
65
+ @client.rest_request(:delete, path, query: query)
66
+ end
67
+
68
+ # GET api/rest/view/v1/app/data
69
+ def app_data = get("#{VIEW}/v1/app/data")
70
+
71
+ # GET api/rest/view/v2/home
72
+ def home = get("#{VIEW}/v2/home")
73
+
74
+ # GET api/rest/view/v3/mobile/data
75
+ def mobile_data = get("#{VIEW}/v3/mobile/data")
76
+
77
+ # GET api/rest/view/v1/schoolyears
78
+ def school_years = get("#{VIEW}/v1/schoolyears")
79
+
80
+ # POST api/rest/view/v1/session/status
81
+ def session_status(client_time_zone: nil)
82
+ post("#{VIEW}/v1/session/status", { "clientTimeZone" => client_time_zone }.compact)
83
+ end
84
+
85
+ # GET api/rest/view/v1/today/meta
86
+ def today_meta = get("#{VIEW}/v1/today/meta")
87
+
88
+ # GET api/rest/view/v1/dashboard/cards
89
+ def dashboard_cards = get("#{VIEW}/v1/dashboard/cards")
90
+
91
+ # GET api/rest/view/v1/messages/status
92
+ def messages_status = get("#{VIEW}/v1/messages/status")
93
+
94
+ # GET api/rest/view/v1/timegrid
95
+ def timegrid = get("#{VIEW}/v1/timegrid")
96
+
97
+ # GET api/rest/view/v1/timetable/grid
98
+ def timetable_grid(timetable_type: "STANDARD")
99
+ get("#{VIEW}/v1/timetable/grid", { "timetableType" => timetable_type })
100
+ end
101
+
102
+ # GET api/rest/view/v1/timetable/filter
103
+ def timetable_filter(resource_type:, timetable_type: "STANDARD", **range)
104
+ start_date, end_date = bounds(range)
105
+ get("#{VIEW}/v1/timetable/filter", {
106
+ "resourceType" => ElementType.label(resource_type),
107
+ "timetableType" => timetable_type,
108
+ "start" => start_date,
109
+ "end" => end_date
110
+ })
111
+ end
112
+
113
+ # GET api/rest/view/v1/timetable/entries; `format` defaults to {#default_format}.
114
+ def timetable_entries(resource_type:, resources:, format: nil, period_types: "",
115
+ timetable_type: "STANDARD", layout: "START_TIME", **range)
116
+ start_date, end_date = bounds(range)
117
+ get("#{VIEW}/v1/timetable/entries", {
118
+ "start" => start_date,
119
+ "end" => end_date,
120
+ "format" => format || default_format(resource_type),
121
+ "resourceType" => ElementType.label(resource_type),
122
+ "resources" => Array(resources),
123
+ "periodTypes" => period_types,
124
+ "timetableType" => timetable_type,
125
+ "layout" => layout
126
+ })
127
+ end
128
+
129
+ # The tenant's timetable format id for a resource type, read once from `timetable/grid`.
130
+ def default_format(resource_type)
131
+ label = ElementType.label(resource_type)
132
+ @default_formats ||= {}
133
+ @default_formats[label] ||= timetable_grid[FORMAT_KEYS.fetch(label)] ||
134
+ raise(Error, "timetable/grid did not report a format for #{label}")
135
+ end
136
+
137
+ # GET api/rest/view/v1/timetable/entriesWeekOverview
138
+ def timetable_week_overview(resource_type:, resources:, timetable_type: nil, **range)
139
+ start_date, end_date = bounds(range)
140
+ get("#{VIEW}/v1/timetable/entriesWeekOverview", {
141
+ "start" => start_date,
142
+ "end" => end_date,
143
+ "resourceType" => ElementType.label(resource_type),
144
+ "resources" => Array(resources),
145
+ "timetableType" => timetable_type
146
+ })
147
+ end
148
+
149
+ # GET api/rest/view/v1/timetable/search
150
+ def timetable_search(query:, school_year_id:)
151
+ get("#{VIEW}/v1/timetable/search", { "q" => query, "schoolyear" => school_year_id })
152
+ end
153
+
154
+ # GET api/rest/view/v1/timetable/menu
155
+ def timetable_menu = get("#{VIEW}/v1/timetable/menu")
156
+
157
+ # GET api/rest/view/v1/timetable/calendar
158
+ def timetable_calendar(my_timetable: false, timetable_type: "STANDARD")
159
+ get("#{VIEW}/v1/timetable/calendar", { "myTimetable" => my_timetable, "timetableType" => timetable_type })
160
+ end
161
+
162
+ # GET api/rest/view/v2/timetable/availableRooms
163
+ def available_rooms(start_date_time:, end_date_time:)
164
+ get("#{VIEW}/v2/timetable/availableRooms", {
165
+ "startDateTime" => Util.to_iso_datetime(start_date_time),
166
+ "endDateTime" => Util.to_iso_datetime(end_date_time)
167
+ })
168
+ end
169
+
170
+ # GET api/rest/view/v2/calendar-entry/detail; this endpoint takes the numeric element type.
171
+ def calendar_entry_detail(element_id:, element_type:, start_date_time:, end_date_time:, homework_option: "DUE")
172
+ get("#{VIEW}/v2/calendar-entry/detail", {
173
+ "elementId" => element_id,
174
+ "elementType" => ElementType.resolve(element_type),
175
+ "startDateTime" => Util.to_iso_datetime(start_date_time),
176
+ "endDateTime" => Util.to_iso_datetime(end_date_time),
177
+ "homeworkOption" => homework_option
178
+ })
179
+ end
180
+
181
+ # GET api/rest/view/v1/teachers
182
+ def teachers(query = {}) = get("#{VIEW}/v1/teachers", query)
183
+
184
+ # GET api/rest/view/v1/students
185
+ def students(query = {}) = get("#{VIEW}/v1/students", query)
186
+
187
+ # GET api/rest/view/v1/subjects
188
+ def subjects(query = {}) = get("#{VIEW}/v1/subjects", query)
189
+
190
+ # GET api/rest/view/v1/rooms
191
+ def rooms(query = {}) = get("#{VIEW}/v1/rooms", query)
192
+
193
+ # GET api/rest/view/v1/students/overview
194
+ def students_overview(query = {}) = get("#{VIEW}/v1/students/overview", query)
195
+
196
+ # GET api/rest/view/v1/teachers/{id}
197
+ def teacher(id) = get("#{VIEW}/v1/teachers/#{id}")
198
+
199
+ # GET api/rest/view/v1/students/{id}
200
+ def student(id) = get("#{VIEW}/v1/students/#{id}")
201
+
202
+ # GET api/rest/view/v1/students/{id}/lessons
203
+ def student_lessons(id) = get("#{VIEW}/v1/students/#{id}/lessons")
204
+
205
+ # GET api/rest/view/v1/students/{id}/class-history
206
+ def student_class_history(id) = get("#{VIEW}/v1/students/#{id}/class-history")
207
+
208
+ # GET api/rest/view/v1/messages
209
+ def messages = get("#{VIEW}/v1/messages")
210
+
211
+ # GET api/rest/view/v1/messages/{id}
212
+ def message(id) = get("#{VIEW}/v1/messages/#{id}")
213
+
214
+ # GET api/rest/view/v1/messages/sent
215
+ def sent_messages = get("#{VIEW}/v1/messages/sent")
216
+
217
+ # GET api/rest/view/v1/messages/drafts
218
+ def drafts = get("#{VIEW}/v1/messages/drafts")
219
+
220
+ # GET api/rest/view/v1/messages/permissions
221
+ def message_permissions = get("#{VIEW}/v1/messages/permissions")
222
+
223
+ # GET api/rest/view/v1/exams
224
+ def exams(with_deleted: false, **range)
225
+ start_date, end_date = bounds(range)
226
+ get("#{VIEW}/v1/exams", { "start" => start_date, "end" => end_date, "withDeleted" => with_deleted })
227
+ end
228
+
229
+ # GET api/rest/view/v1/exams/filter
230
+ def exams_filter(**range)
231
+ start_date, end_date = bounds(range, required: false)
232
+ get("#{VIEW}/v1/exams/filter", { "start" => start_date, "end" => end_date })
233
+ end
234
+
235
+ # GET api/rest/view/v1/exams/statistics
236
+ def exam_statistics(**range)
237
+ start_date, end_date = bounds(range)
238
+ get("#{VIEW}/v1/exams/statistics", { "start" => start_date, "end" => end_date })
239
+ end
240
+
241
+ # GET api/rest/view/v1/exam-types
242
+ def exam_types_v1 = get("#{VIEW}/v1/exam-types")
243
+
244
+ # GET api/rest/view/v1/classreg/homework/meta
245
+ def homework_meta = get("#{VIEW}/v1/classreg/homework/meta")
246
+
247
+ # POST api/rest/view/v1/classreg/homework/list
248
+ def homework_list(body) = post("#{VIEW}/v1/classreg/homework/list", body)
249
+
250
+ # GET api/rest/view/v4/classreg/absences
251
+ def absences(query = {}) = get("#{VIEW}/v4/classreg/absences", query)
252
+
253
+ # GET api/rest/view/v1/classreg/open-periods
254
+ def open_periods(query = {}) = get("#{VIEW}/v1/classreg/open-periods", query)
255
+
256
+ # GET api/public/timetable/weekly/data (legacy; ISO date)
257
+ def weekly_timetable(element_type:, element_id:, date:, format_id: 1)
258
+ get("api/public/timetable/weekly/data", {
259
+ "elementType" => ElementType.resolve(element_type),
260
+ "elementId" => element_id,
261
+ "date" => Util.to_iso_date(date),
262
+ "formatId" => format_id
263
+ })
264
+ end
265
+
266
+ # GET api/public/news/newsWidgetData (legacy)
267
+ def news_widget(date:)
268
+ get("api/public/news/newsWidgetData", { "date" => Util.to_untis_date(date) })
269
+ end
270
+
271
+ # GET api/homeworks/lessons (legacy)
272
+ def homeworks(start_date:, end_date:)
273
+ get("api/homeworks/lessons", {
274
+ "startDate" => Util.to_untis_date(start_date),
275
+ "endDate" => Util.to_untis_date(end_date)
276
+ })
277
+ end
278
+
279
+ # GET api/exams (legacy)
280
+ def legacy_exams(start_date:, end_date:, klasse_id: nil, with_grades: false)
281
+ get("api/exams", {
282
+ "startDate" => Util.to_untis_date(start_date),
283
+ "endDate" => Util.to_untis_date(end_date),
284
+ "klasseId" => klasse_id,
285
+ "withGrades" => with_grades
286
+ })
287
+ end
288
+
289
+ # GET api/classreg/absences/students (legacy); `-1` means every student / every excuse status.
290
+ def student_absences(start_date:, end_date:, student_id: -1, excuse_status_id: -1)
291
+ get("api/classreg/absences/students", {
292
+ "startDate" => Util.to_untis_date(start_date),
293
+ "endDate" => Util.to_untis_date(end_date),
294
+ "studentId" => student_id,
295
+ "excuseStatusId" => excuse_status_id
296
+ })
297
+ end
298
+
299
+ # GET api/app/config (legacy)
300
+ def app_config = get("api/app/config")
301
+
302
+ # GET api/daytimetable/config (legacy)
303
+ def daytimetable_config = get("api/daytimetable/config")
304
+
305
+ private
306
+
307
+ def bounds(range, required: true)
308
+ unknown = range.keys - RANGE_KEYS
309
+ raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" unless unknown.empty?
310
+
311
+ start_value = range[:start] || range[:start_date]
312
+ end_value = range[:end] || range[:end_date]
313
+ missing = { start: start_value, end: end_value }.filter_map { |name, value| name if value.nil? }
314
+ raise ArgumentError, "missing keywords: #{missing.join(", ")}" if required && !missing.empty?
315
+
316
+ [Util.to_iso_date(start_value), Util.to_iso_date(end_value)]
317
+ end
318
+ end
319
+ end