148 lines
6.4 KiB
C#
148 lines
6.4 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, ordered by <see cref="Faq.Topic"/>, then by
|
|
/// the editor's <c>order</c> within each topic.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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. The per-topic <c>order</c> field lets
|
|
/// an editor arrange the questions within a topic and keeps that arrangement
|
|
/// stable between requests; the topic groups themselves stay alphabetical.
|
|
/// </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", token);
|
|
|
|
/// <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>
|
|
/// <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)
|
|
{
|
|
// 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<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);
|
|
}
|
|
}
|
|
}
|