Add PocketBase client with contracts and sharp build tooling
This commit is contained in:
1 parent
56922707ea
commit
251f2d818b
24 files changed
+578
-20
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elternbeirat.Contracts\Elternbeirat.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.PocketBase;
|
||||
|
||||
/// <summary>
|
||||
/// Shared parsing of a PocketBase date string as wall-clock time. The trailing
|
||||
/// "Z" is stripped rather than honoured, so the number is taken at face value and
|
||||
/// the result carries <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
|
||||
/// </summary>
|
||||
internal static class WallClock
|
||||
{
|
||||
public static DateTime Parse(string raw)
|
||||
{
|
||||
// Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC
|
||||
// and convert it to local time (which would shift 19:30 to 20:30/21:30).
|
||||
var value = raw.EndsWith('Z') ? raw[..^1] : raw;
|
||||
var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None);
|
||||
return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads PocketBase date values as local wall-clock time.
|
||||
/// <para>
|
||||
/// PocketBase stores every date in UTC and serializes it with a trailing "Z"
|
||||
/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number
|
||||
/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone
|
||||
/// decision in the data model. This converter therefore parses the value and
|
||||
/// returns it as an <see cref="DateTimeKind.Unspecified"/> instant, so no
|
||||
/// timezone shift is ever applied when the value is later formatted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
|
||||
{
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString();
|
||||
return string.IsNullOrEmpty(raw)
|
||||
? default
|
||||
: WallClock.Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
|
||||
CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
|
||||
/// an empty string for an unset optional date (e.g. an event without an end);
|
||||
/// that maps to null.
|
||||
/// </summary>
|
||||
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
|
||||
{
|
||||
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
return null;
|
||||
|
||||
return WallClock.Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
writer.WriteStringValue("");
|
||||
else
|
||||
writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
|
||||
CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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.
|
||||
/// <para>
|
||||
/// One method per collection (pages, posts, events, faqs). Each returns only
|
||||
/// records with <c>public = true</c> and lets PocketBase do the filtering and
|
||||
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
|
||||
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
|
||||
/// is registered as a typed client via <c>AddHttpClient</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PocketBaseClient(HttpClient http)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
options.Converters.Add(new LocalDateTimeConverter());
|
||||
options.Converters.Add(new NullableLocalDateTimeConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>Gets all public pages, ordered for navigation.</summary>
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||
|
||||
/// <summary>Gets all public posts, newest first.</summary>
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||
|
||||
/// <summary>Gets all public events, earliest start first.</summary>
|
||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||
|
||||
/// <summary>Gets all public FAQ entries.</summary>
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||
|
||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct)
|
||||
{
|
||||
// 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)}";
|
||||
|
||||
var result = await http.GetFromJsonAsync<RecordList<T>>(url, JsonOptions, ct);
|
||||
return result?.Items ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elternbeirat.PocketBase;
|
||||
|
||||
/// <summary>
|
||||
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
|
||||
/// is used; the paging fields are ignored because content volumes are small and the
|
||||
/// client requests a large page size in a single call.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam>
|
||||
internal sealed record RecordList<T>
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<T> Items { get; init; } = [];
|
||||
}
|
||||
Reference in new issue
Block a user