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 , each with /// its questions expanded. /// /// /// Backs the FAQ page, which shows every topic as a collapsible group with its /// questions nested inside. The faqs_via_topic back-relation is expanded so /// one request returns the topics and their questions together; drafts among the /// questions are dropped in . /// /// /// /// The public FAQ topics with their questions, or an empty list if there are none. /// /// public Task> GetFaqTopicsAsync(CancellationToken token = default) => GetRecordsAsync("faq_topics", "order", token, expand: "faqs_via_topic"); /// /// Checks whether PocketBase answers its health endpoint. /// /// /// Backs the app's own /health endpoint, which an external monitor (Uptime /// Kuma) polls: the app answering at all proves the app is up, and this probe adds /// whether the content source behind it is reachable, so one monitor covers both. /// This calls GET /api/health, PocketBase's own liveness endpoint, which /// needs no auth and touches no collection. /// /// /// A token to cancel the probe. /// /// /// if PocketBase answered with a success status within the /// client timeout; if it was unreachable, timed out, or /// answered with an error status. /// public async Task IsHealthyAsync(CancellationToken token = default) { try { using var response = await httpClient.GetAsync( new Uri("/api/health", UriKind.Relative), token); return response.IsSuccessStatusCode; } // Same failure shapes as a record read: unreachable (HttpRequestException) or the // client's own timeout (a TaskCanceledException wrapping a TimeoutException). A // plain TaskCanceledException is the caller's cancellation and propagates. catch (Exception exception) when ( exception is HttpRequestException or TaskCanceledException { InnerException: TimeoutException }) { return false; } } /// /// 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); } } }