Files

215 lines
9.7 KiB
C#

using System.Net.Http.Json;
using System.Text.Json;
using Elternbeirat.Contracts;
namespace Elternbeirat.PocketBase;
/// <summary>
/// Reads published content from a PocketBase instance over its REST API.
/// </summary>
/// <remarks>
/// <para>
/// There is one method per collection (pages, posts, events, faqs). Each returns
/// only records with <c>public = true</c> and leaves filtering and sorting to
/// PocketBase via query parameters.
/// </para>
/// <para>
/// Responses are deserialized with the web defaults. Date conversion from UTC
/// to Berlin wall-clock time is declared on the contracts themselves via
/// <c>[JsonConverter]</c> (e.g. <see cref="Event.Start"/>, <see cref="Post.Date"/>),
/// so no serializer options are configured here.
/// </para>
/// <para>
/// Register the class as a typed client via <c>AddHttpClient</c>, with
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
/// </para>
/// </remarks>
/// <param name="httpClient">
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
/// </param>
public sealed class PocketBaseClient(HttpClient httpClient)
{
/// <summary>
/// Gets all public pages, ordered by <see cref="Page.Order"/>.
/// </summary>
/// <param name="token">
/// A token to cancel the request.
/// </param>
/// <returns>
/// The public pages, or an empty list if there are none.
/// </returns>
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken token = default)
=> GetRecordsAsync<Page>("pages", "order", token);
/// <summary>
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public posts, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken token = default)
=> GetRecordsAsync<Post>("posts", "-date", token);
/// <summary>
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
/// </summary>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public events, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken token = default)
=> GetRecordsAsync<Event>("events", "start", token);
/// <summary>
/// Gets all public FAQ entries, grouped by topic: ordered by the topic's own
/// <c>order</c>, then by the entry's <c>order</c> within that topic. Each entry
/// carries its expanded <see cref="Faq.Topic"/>.
/// </summary>
/// <remarks>
/// Sorting by <c>topic.order</c> 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 <c>order</c> 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.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ entries, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic");
/// <summary>
/// Gets all public FAQ topics, ordered by <see cref="FaqTopic.Order"/>, each with
/// its questions expanded.
/// </summary>
/// <remarks>
/// Backs the FAQ page, which shows every topic as a collapsible group with its
/// questions nested inside. The <c>faqs_via_topic</c> back-relation is expanded so
/// one request returns the topics and their questions together; drafts among the
/// questions are dropped in <see cref="FaqTopic.Faqs"/>.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
/// The public FAQ topics with their questions, or an empty list if there are none.
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<FaqTopic>> GetFaqTopicsAsync(CancellationToken token = default)
=> GetRecordsAsync<FaqTopic>("faq_topics", "order", token, expand: "faqs_via_topic");
/// <summary>
/// Checks whether PocketBase answers its health endpoint.
/// </summary>
/// <remarks>
/// Backs the app's own <c>/health</c> 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 <c>GET /api/health</c>, PocketBase's own liveness endpoint, which
/// needs no auth and touches no collection.
/// </remarks>
/// <param name="token">
/// A token to cancel the probe.
/// </param>
/// <returns>
/// <see langword="true"/> if PocketBase answered with a success status within the
/// client timeout; <see langword="false"/> if it was unreachable, timed out, or
/// answered with an error status.
/// </returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Gets all public records of a collection in a single request.
/// </summary>
/// <typeparam name="T">
/// The record type to deserialize into.
/// </typeparam>
/// <param name="collection">
/// The PocketBase collection name, e.g. <c>"posts"</c>.
/// </param>
/// <param name="sort">
/// The PocketBase sort expression; a leading <c>-</c> sorts descending.
/// </param>
/// <param name="token">
/// A token to cancel the request.
/// </param>
/// <param name="expand">
/// A PocketBase <c>expand</c> expression naming relations to resolve inline
/// (comma-separated, dot-nested), or <see langword="null"/> to expand none.
/// </param>
/// <returns>
/// The public records, or an empty list if there are none.
/// </returns>
/// <exception cref="PocketBaseUnavailableException">
/// PocketBase could not be reached, timed out, or returned an unreadable response.
/// </exception>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was canceled by the caller.
/// </exception>
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
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<RecordList<T>>(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);
}
}
}