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, ordered by , then by
/// .
///
///
/// The second sort key makes the order total: sorting by topic alone leaves
/// entries within a topic in an undefined order, so the same content could
/// render differently between requests. Adding the question as a tie-breaker
/// keeps the FAQ list and any teaser drawn from it stable.
///
///
///
/// The public FAQ entries, or an empty list if there are none.
///
///
public Task> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync("faqs", "topic,question", token);
///
/// 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.
///
///
/// 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)
{
// 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)}";
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);
}
}
}