Files
LehrerApp/LehrerApp.WebUntis/WebUntisClient.cs
T
2026-08-24 22:00:26 +02:00

686 lines
34 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace LehrerApp.WebUntis;
public sealed class WebUntisClient : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly HttpClient _http;
private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
public WebUntisClient(HttpClient http, WebUntisOptions options)
{
_http = http;
_options = options;
_sessionExpiryTimer = new Timer(
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
this,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
}
public Task<IReadOnlyList<UntisSchoolYear>> GetSchoolYearsAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Schuljahr-Liste geliefert.");
return (IReadOnlyList<UntisSchoolYear>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen.");
return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClass>> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId,
cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert.");
return (IReadOnlyList<UntisClass>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen.");
return new UntisClass(id, name, OptionalString(entry, "longName"),
OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"),
OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTeacher>> GetTeachersAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Lehrer-Liste geliefert.");
return (IReadOnlyList<UntisTeacher>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel.");
var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array
? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).Distinct().Order().ToList()
: [];
return new UntisTeacher(id, name, OptionalString(entry, "foreName"),
OptionalString(entry, "longName"), OptionalString(entry, "title"),
OptionalBoolean(entry, "active"), departments);
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisHoliday>> GetHolidaysAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Ferien-Liste geliefert.");
return (IReadOnlyList<UntisHoliday>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen.");
return new UntisHoliday(id, name, OptionalString(entry, "longName"),
RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimeGridDay>> GetTimeGridAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Zeitraster-Daten geliefert.");
return (IReadOnlyList<UntisTimeGridDay>)entries.Select(entry =>
{
var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array
? values.EnumerateArray().Select(unit => new UntisTimeUnit(
OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime")))
.ToList()
: [];
return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone());
}).ToList();
}, cancellationToken);
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var reportData = await RequestReportAsync(sessionId, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
var allStudents = WebUntisStudentReportParser.Parse(reportText);
var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim();
var students = normalizedFilter is null
? allStudents
: allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter,
StringComparison.OrdinalIgnoreCase)).ToList();
return new UntisStudentReport(students.Count, normalizedFilter, students);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSubstitutions", new
{
startDate,
endDate,
departmentId = departmentId ?? 0,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert.");
return (IReadOnlyList<UntisSubstitution>)entries.Select(entry => new UntisSubstitution(
OptionalString(entry, "type") ?? "unknown",
OptionalInt(entry, "lsid"),
OptionalString(entry, "lstype"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalString(entry, "txt"),
Entities(entry, "kl"),
Entities(entry, "te"),
Entities(entry, "su"),
Entities(entry, "ro"),
ParseReschedule(entry),
entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimetablePeriod>> GetTimetableAsync(UntisTimetableElementType elementType,
int elementId, int startDate, int endDate, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var options = new
{
element = new { id = elementId, type = (int)elementType },
startDate,
endDate,
showBooking = false,
showInfo = true,
showSubstText = true,
showLsText = true,
showLsNumber = true,
showStudentgroup = true,
};
var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Stundenplan-Daten geliefert.");
return (IReadOnlyList<UntisTimetablePeriod>)entries.Select(entry => new UntisTimetablePeriod(
RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"),
OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"),
OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"),
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var result = await RpcAsync("getTimetableWithAbsences", new
{
options = new { startDate, endDate },
}, sessionId, cancellationToken);
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
var absences = periods.EnumerateArray()
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
.Select(entry => new UntisStudentAbsence(
RequiredInt(entry, "studentId"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalInt(entry, "absentTime") ?? 0,
OptionalBoolean(entry, "checked"),
OptionalString(entry, "absenceReason"),
OptionalString(entry, "excuseStatus"),
OptionalInt(entry, "subjectId"),
IntArray(entry, "teacherIds"),
OptionalString(entry, "studentGroup"),
entry.Clone()))
.OrderBy(entry => entry.Date)
.ThenBy(entry => entry.StartTime)
.ToList();
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
absences.Sum(entry => entry.AbsentMinutes), absences);
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregEvents", new
{
startDate,
endDate,
id = studentId,
type = (int)UntisTimetableElementType.Student,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert.");
return (IReadOnlyList<UntisClassRegisterEntry>)entries.Select(entry =>
{
var surname = OptionalString(entry, "surname");
var foreName = OptionalString(entry, "forname");
var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null));
return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName,
displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"),
OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"),
OptionalString(entry, "text"), entry.Clone());
}).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategory>> GetClassRegisterCategoriesAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert.");
return (IReadOnlyList<UntisClassRegisterCategory>)entries.Select(entry =>
new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "",
OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategoryGroup>> GetClassRegisterCategoryGroupsAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId,
cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert.");
return (IReadOnlyList<UntisClassRegisterCategoryGroup>)entries.Select(entry =>
new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"),
OptionalString(entry, "name") ?? "", entry.Clone())).ToList();
}, cancellationToken);
private async Task<T> WithSessionAsync<T>(Func<string, Task<T>> action, CancellationToken cancellationToken)
{
await using var lease = await AcquireSessionAsync(cancellationToken);
return await action(lease.SessionId);
}
private async Task<SessionLease> AcquireSessionAsync(CancellationToken cancellationToken)
{
await _sessionGate.WaitAsync(cancellationToken);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
_sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
// Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits
// abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden.
if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow)
{
var expiredSessionId = _sessionId;
_sessionId = null;
await TryLogoutAsync(expiredSessionId);
}
if (_sessionId is null)
{
var configuration = GetConfiguration();
var result = await RpcAsync("authenticate", new
{
user = configuration.Username,
password = configuration.Password,
client = configuration.Client,
}, null, cancellationToken);
_sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
}
_activeRequests++;
return new SessionLease(this, _sessionId);
}
finally
{
_sessionGate.Release();
}
}
private async ValueTask ReleaseSessionAsync()
{
await _sessionGate.WaitAsync();
try
{
if (_activeRequests > 0) _activeRequests--;
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var timeout = SessionIdleTimeout;
_sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout);
_sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan);
}
finally
{
_sessionGate.Release();
}
}
private async Task CloseExpiredSessionAsync()
{
string? sessionId = null;
await _sessionGate.WaitAsync();
try
{
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero)
{
_sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan);
return;
}
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private async Task TryLogoutAsync(string sessionId)
{
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
}
public async ValueTask DisposeAsync()
{
string? sessionId;
await _sessionGate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
await _sessionExpiryTimer.DisposeAsync();
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private TimeSpan SessionIdleTimeout =>
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken)
{
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions),
};
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
if (payload.ValueKind != JsonValueKind.Object)
throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method}).");
if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}");
if (!TryProperty(payload, "result", out var result))
throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis.");
return result.Clone();
}
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4))
return null;
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
if (!OptionalBoolean(data, "finished")) return null;
return ReportData.From(data);
}
private async Task<ReportData> PollReportAsync(string sessionId, CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT";
for (var attempt = 0; attempt < 60; attempt++)
{
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}.");
if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) &&
jobs.ValueKind == JsonValueKind.Array)
{
foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished")))
{
if (OptionalBoolean(job, "hasJobError"))
throw new WebUntisException("Report-Polling enthält einen Job-Fehler.");
if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData);
}
}
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt.");
}
private async Task<string> FetchReportTextAsync(string sessionId, ReportData reportData,
CancellationToken cancellationToken)
{
var uri = reportData.ReportParams is not null
? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}"
: $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}";
using var request = ReportRequest(uri, sessionId, acceptJson: false);
using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
var payload = ParseJson(bytes);
throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}.");
try { return StrictUtf8.GetString(bytes); }
catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); }
}
private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson)
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
return request;
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception);
}
catch (HttpRequestException exception)
{
throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception);
}
}
private Config GetConfiguration()
{
var schoolValue = _options.School.Trim();
var username = _options.Username.Trim();
// Passwörter dürfen führende/abschließende Leerzeichen enthalten und werden deshalb
// anders als Schule/Benutzername nicht normalisiert.
var password = _options.Password;
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
var (school, host) = ResolveLocation(schoolValue, _options.Host);
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns) ||
!(host.Equals("webuntis.com", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".webuntis.com", StringComparison.OrdinalIgnoreCase)))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
return new Config(school, host, username, password,
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
}
private static (string School, string Host) ResolveLocation(string schoolValue, string? hostValue)
{
var school = schoolValue.Trim();
string? hostFromSchool = null;
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
{
hostFromSchool = schoolLocation.Host;
school = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool
? querySchool
: schoolLocation.Host.Split('.')[0];
}
string host;
if (!string.IsNullOrWhiteSpace(hostValue))
{
if (!TryParseLocation(hostValue, out var hostLocation))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
host = hostLocation.Host;
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
if (LooksLikeLocation(schoolValue) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool)
school = querySchool;
}
else
{
host = hostFromSchool ?? $"{school}.webuntis.com";
}
if (school.Length == 0)
throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt oder ist ungültig.");
return (school, host);
}
private static bool LooksLikeLocation(string value) => value.Contains('.') || value.Contains('/') ||
value.StartsWith("http:", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("https:", StringComparison.OrdinalIgnoreCase);
private static bool TryParseLocation(string value, out Uri location)
{
var candidate = value.Trim();
if (!candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
candidate = $"https://{candidate}";
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host);
}
private static string? QueryValue(Uri uri, string key)
{
foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var pair = part.Split('=', 2);
if (!Uri.UnescapeDataString(pair[0]).Equals(key, StringComparison.OrdinalIgnoreCase)) continue;
return pair.Length == 2
? Uri.UnescapeDataString(pair[1].Replace('+', ' ')).Trim()
: "";
}
return null;
}
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
ParseJson(await response.Content.ReadAsByteArrayAsync(token));
private static JsonElement ParseJson(byte[] bytes)
{
try { return JsonSerializer.Deserialize<JsonElement>(bytes, JsonOptions); }
catch (JsonException) { return default; }
}
private static IReadOnlyList<JsonElement> RequireArray(JsonElement value, string error) =>
value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList()
: throw new WebUntisException(error);
private static IReadOnlyList<UntisEntity> Entities(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0,
OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"),
OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList()
: [];
private static IReadOnlyList<int> IntArray(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).ToList()
: [];
private static UntisReschedule? ParseReschedule(JsonElement parent)
{
if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null;
var date = OptionalInt(value, "date");
var start = OptionalInt(value, "startTime");
var end = OptionalInt(value, "endTime");
return date is not null && start is not null && end is not null
? new UntisReschedule(date.Value, start.Value, end.Value)
: null;
}
private static bool TryProperty(JsonElement value, string name, out JsonElement property)
{
property = default;
return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property);
}
private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property)
?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig.");
private static int? OptionalInt(JsonElement parent, string property) =>
TryProperty(parent, property, out var value) ? OptionalInt(value) : null;
private static int? OptionalInt(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null;
}
private static string? OptionalString(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return null;
var text = value.ValueKind == JsonValueKind.String ? value.GetString() :
value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null;
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
private static bool OptionalBoolean(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return false;
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean();
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0;
return value.ValueKind == JsonValueKind.String &&
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
}
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
{
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
if (payload.ValueKind == JsonValueKind.Object)
{
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
return errorMessage;
if (OptionalString(payload, "message") is { } message) return message;
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
return dataMessage;
}
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
}
private sealed record Config(string School, string Host, string Username, string Password, string Client);
private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable
{
private int _released;
public string SessionId { get; } = sessionId;
public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0
? owner.ReleaseSessionAsync()
: ValueTask.CompletedTask;
}
private sealed record ReportData(string? ReportParams, string? MessageId)
{
public static ReportData From(JsonElement data)
{
var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId"));
return result.ReportParams is null && result.MessageId is null
? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.")
: result;
}
}
}