using System.Net.Http.Json; using System.Text.Json; using Elternbeirat.Contracts; namespace Elternbeirat.PocketBase; /// /// Reads published content from a PocketBase instance over its REST API. /// /// /// /// There is one method per collection (pages, posts, events, faqs). Each returns /// only records with public = true and leaves filtering and sorting to /// PocketBase via query parameters. /// /// /// Responses are deserialized with the web defaults. Date conversion from UTC /// to Berlin wall-clock time is declared on the contracts themselves via /// [JsonConverter] (e.g. , ), /// so no serializer options are configured here. /// /// /// Register the class as a typed client via AddHttpClient, with /// set to the PocketBase base URL. /// /// /// /// The HTTP client; its must point to PocketBase. /// public sealed class PocketBaseClient(HttpClient httpClient) { /// /// Gets all public pages, ordered by . /// /// /// A token to cancel the request. /// /// /// The public pages, or an empty list if there are none. /// /// /// PocketBase could not be reached, timed out, or returned an unreadable response. /// /// /// was canceled by the caller. /// public Task> GetPagesAsync(CancellationToken token = default) => GetRecordsAsync("pages", "order", token); /// /// Gets all public posts, ordered by , newest first. /// /// /// /// The public posts, or an empty list if there are none. /// /// public Task> GetPostsAsync(CancellationToken token = default) => GetRecordsAsync("posts", "-date", token); /// /// Gets all public events, ordered by , earliest first. /// /// /// /// The public events, or an empty list if there are none. /// /// public Task> GetEventsAsync(CancellationToken token = default) => GetRecordsAsync("events", "start", token); /// /// Gets all public FAQ entries, grouped by topic: ordered by the topic's own /// order, then by the entry's order within that topic. Each entry /// carries its expanded . /// /// /// Sorting by topic.order first keeps all questions of a topic together /// and lists the topic groups in the order an editor gave the topics; the /// entry's own order then arranges the questions within each group. Both /// keys together make the order total, so the same content renders the same way /// between requests. The topic relation is expanded so the page can show each /// group's heading without a second request. /// /// /// /// The public FAQ entries, or an empty list if there are none. /// /// public Task> GetFaqsAsync(CancellationToken token = default) => GetRecordsAsync("faqs", "topic.order,order", token, expand: "topic"); /// /// Gets all public FAQ topics, ordered by . /// /// /// Backs the FAQ overview, which lists the topics as links to their own pages. /// The questions are not loaded here; the topic page loads them per topic via /// . /// /// /// /// The public FAQ topics, or an empty list if there are none. /// /// public Task> GetFaqTopicsAsync(CancellationToken token = default) => GetRecordsAsync("faq_topics", "order", token); /// /// Gets one public FAQ topic by its slug, with its questions expanded, or /// when no public topic has that slug. /// /// /// Backs the topic's own page at /faqs/{slug}: it loads the single topic /// and, through the faqs_via_topic back-relation, the questions grouped /// under it, so the page needs one request. The slug is unique, so at most one /// record matches. /// /// /// The topic slug from the URL, e.g. "cafeteria". /// /// /// /// The matching public topic with its questions, or if /// none matches. /// /// public async Task GetFaqTopicBySlugAsync( string slug, CancellationToken token = default) { var topics = await GetRecordsAsync( "faq_topics", "order", token, expand: "faqs_via_topic"); return topics.FirstOrDefault( topic => string.Equals(topic.Slug, slug, StringComparison.OrdinalIgnoreCase)); } /// /// Gets all public records of a collection in a single request. /// /// /// The record type to deserialize into. /// /// /// The PocketBase collection name, e.g. "posts". /// /// /// The PocketBase sort expression; a leading - sorts descending. /// /// /// A token to cancel the request. /// /// /// A PocketBase expand expression naming relations to resolve inline /// (comma-separated, dot-nested), or to expand none. /// /// /// The public records, or an empty list if there are none. /// /// /// PocketBase could not be reached, timed out, or returned an unreadable response. /// /// /// was canceled by the caller. /// private async Task> GetRecordsAsync( string collection, string sort, CancellationToken token, string? expand = null) { // filter=public=true keeps drafts out; perPage is large enough to fetch // every record in a single page given the small content volume. var url = $"/api/collections/{collection}/records" + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; // Add expand only when asked, so callers that need no relations send the // leaner request they did before. if (expand is not null) url += $"&expand={Uri.EscapeDataString(expand)}"; try { var result = await httpClient.GetFromJsonAsync>(url, token); return result?.Items ?? []; } // These three mean PocketBase itself failed: it was unreachable // (HttpRequestException), the request to it timed out (a TaskCanceledException // whose inner exception is a TimeoutException, which is how HttpClient surfaces // its own timeout), or it answered with something we could not read // (JsonException). A plain TaskCanceledException with no inner TimeoutException // is the caller's own cancellation via token and is deliberately not caught -- // it propagates as OperationCanceledException so an aborted request stays an // abort, not a fault. catch (Exception exception) when ( exception is HttpRequestException or JsonException or TaskCanceledException { InnerException: TimeoutException }) { throw new PocketBaseUnavailableException($"Could not read collection '{collection}'.", exception); } } }