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 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);
}
}
}