The client's JSON comes from generated metadata only
Build / Build (push) Successful in 32s

PrivaPub's own types are read through the shared ClientModels context, and a type without generated metadata no longer
falls back to reflection silently: it fails at once in development and on the pasture, and is reported on the console
elsewhere. Startup outside production checks that every PrivaPub and Mastodon type the client knows has its metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-10 07:28:09 +01:00
1 parent 3bc511421e
commit dca046e27d
6 files changed
+70 -105

No files matched your search

+14 -4
View File
@@ -259,7 +259,8 @@ Never redeclare the base's members in a component.
- The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token - The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token
(exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing. (exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing.
- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`, - It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`,
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext` then
`ClientModelsJsonContext`) for `clientapi/`. A payload
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt. can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
@@ -954,14 +955,23 @@ and `ToCssNumber` for values written into a style. Date formats come from `SUtil
`dateTimeFormatExt`. Relative times and sizes come from `GetPassedTime` / `GetFileSize` `dateTimeFormatExt`. Relative times and sizes come from `GetPassedTime` / `GetFileSize`
(`Extensions/GenericExtensions.cs`). No inline format strings for display. (`Extensions/GenericExtensions.cs`). No inline format strings for display.
**JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads **JSON (HR0306).** Every type that crosses HTTP or is stored has source-generated metadata:
`pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case). - `PrivaPub.ClientModels` types are registered in that project's `ClientModelsJsonContext`, shared with PrivaPub;
- the client's own `/clientapi` and localStorage types in `ClientJsonContext.cs` (camelCase: `theme.js` reads
`pageSettings` straight from localStorage);
- every Mastodon API type in `MastodonJsonContext.cs` (snake_case).
There is no silent reflection fallback. The last resolver in each chain, `Helpers/UnregisteredJson`, throws for an
unregistered type in Development and the pasture, and elsewhere writes it to the console once and serializes it anyway.
At startup outside Production, `UnregisteredJson.Check` fails when a class in `PrivaPub.ClientModels` or
`Models/Mastodon/` has no metadata.
**Wiring a feature to PrivaPub:** **Wiring a feature to PrivaPub:**
1. For `/clientapi`, add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for 1. For `/clientapi`, add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for
responses, `*Request` for requests, `*Form` for forms with validation. Lists `= []`, no string defaults. For the responses, `*Request` for requests, `*Form` for forms with validation. Lists `= []`, no string defaults. For the
Mastodon API, add the shape to `Models/Mastodon/`. Mastodon API, add the shape to `Models/Mastodon/`.
2. Register them in `ClientJsonContext` or `MastodonJsonContext`, and the route in `Models/APIs.cs`. 2. Register them in `ClientModelsJsonContext` (SocialPub), `ClientJsonContext` or `MastodonJsonContext`, `List<T>` too
when a list is read, and the route in `Models/APIs.cs`.
3. Add a client service method that calls `IHttpService` against the server's route, following the patterns above. 3. Add a client service method that calls `IHttpService` against the server's route, following the patterns above.
4. Wire the page with early returns, toasts and/or an `ErrorViewer`. 4. Wire the page with early returns, toasts and/or an `ErrorViewer`.
+3 -97
View File
@@ -2,20 +2,13 @@
using decePubClient.Models; using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.Group;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient; namespace decePubClient;
/// <summary> /// <summary>
/// Source-generated JSON metadata for the client's own types, used first by SUtility.DefaultSerializer (HTTP) and by /// Source-generated JSON metadata for the client's own types, used first by SUtility.DefaultSerializer (HTTP) and by
/// Blazored.LocalStorage (Program.cs). Register every type that crosses HTTP or is stored in localStorage here; types /// Blazored.LocalStorage (Program.cs); PrivaPub.ClientModels' types come from ClientModelsJsonContext. A type missing
/// missing from it still work through the reflection fallback. Names stay camelCase: wwwroot/js/theme.js reads /// from both fails in Development and the pasture, and is reported on the console elsewhere. Names stay camelCase:
/// PublicCacheData.pageSettings straight from localStorage. /// wwwroot/js/theme.js reads PublicCacheData.pageSettings straight from localStorage.
/// </summary> /// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true)] IgnoreReadOnlyProperties = true)]
@@ -30,96 +23,9 @@ namespace decePubClient;
[JsonSerializable(typeof(PushDevice))] [JsonSerializable(typeof(PushDevice))]
[JsonSerializable(typeof(UploadMedia))] [JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))]
[JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(SetupForm))]
[JsonSerializable(typeof(InvitationLoginForm))]
[JsonSerializable(typeof(ViewServerSetup))]
[JsonSerializable(typeof(UserPasswordForm))]
[JsonSerializable(typeof(RemoveSelfForm))]
[JsonSerializable(typeof(PasswordRecoveryForm))]
[JsonSerializable(typeof(NewPasswordForm))]
[JsonSerializable(typeof(ViewAccount))]
[JsonSerializable(typeof(EmailChangeForm))]
[JsonSerializable(typeof(EmailCodeForm))]
[JsonSerializable(typeof(PasswordForm))]
[JsonSerializable(typeof(TotpCodeForm))]
[JsonSerializable(typeof(TotpDisableForm))]
[JsonSerializable(typeof(ViewTotpSetup))]
[JsonSerializable(typeof(ViewTotpEnabled))]
[JsonSerializable(typeof(ViewUserSettings))]
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool // recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))] [JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(JwtUser))]
[JsonSerializable(typeof(ViewAvatar))]
[JsonSerializable(typeof(List<ViewAvatar>))]
[JsonSerializable(typeof(InsertAvatarForm))]
[JsonSerializable(typeof(ViewPersonaPreferences))]
[JsonSerializable(typeof(PersonaPreferencesForm))]
[JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewGroupMember))]
[JsonSerializable(typeof(List<ViewGroupMember>))]
[JsonSerializable(typeof(InsertGroupForm))]
[JsonSerializable(typeof(UpdateGroupForm))]
[JsonSerializable(typeof(JoinGroupForm))]
[JsonSerializable(typeof(GroupMembershipForm))]
[JsonSerializable(typeof(ViewPost))]
[JsonSerializable(typeof(List<ViewPost>))]
[JsonSerializable(typeof(InsertPostForm))]
[JsonSerializable(typeof(DeletePostForm))]
[JsonSerializable(typeof(ViewReport))]
[JsonSerializable(typeof(List<ViewReport>))]
[JsonSerializable(typeof(DomainBlockForm))]
[JsonSerializable(typeof(ViewDomainBlock))]
[JsonSerializable(typeof(List<ViewDomainBlock>))]
[JsonSerializable(typeof(ViewStatisticsOverview))]
[JsonSerializable(typeof(ViewHostsPage))]
[JsonSerializable(typeof(ViewCrawler))]
[JsonSerializable(typeof(ViewHostDetail))]
[JsonSerializable(typeof(ViewHostInstance))]
[JsonSerializable(typeof(ViewHostDay))]
[JsonSerializable(typeof(ViewInteractionEvent))]
[JsonSerializable(typeof(List<ViewInteractionEvent>))]
[JsonSerializable(typeof(ViewServerDay))]
[JsonSerializable(typeof(List<ViewServerDay>))]
[JsonSerializable(typeof(ViewBackups))]
[JsonSerializable(typeof(ViewBackup))]
[JsonSerializable(typeof(BackupPasswordForm))]
[JsonSerializable(typeof(RestoreBackupForm))]
[JsonSerializable(typeof(ViewBackupTicket))]
[JsonSerializable(typeof(ViewBackupUpload))]
[JsonSerializable(typeof(ViewPersonaArchive))]
[JsonSerializable(typeof(ImportPersonaArchiveForm))]
[JsonSerializable(typeof(ViewArchiveTicket))]
[JsonSerializable(typeof(ViewArchiveUpload))]
[JsonSerializable(typeof(ViewServerSettings))]
[JsonSerializable(typeof(ServerSettingsForm))]
[JsonSerializable(typeof(ViewAnnouncement))]
[JsonSerializable(typeof(List<ViewAnnouncement>))]
[JsonSerializable(typeof(AnnouncementForm))]
[JsonSerializable(typeof(ViewRelay))]
[JsonSerializable(typeof(List<ViewRelay>))]
[JsonSerializable(typeof(RelayForm))]
[JsonSerializable(typeof(ViewStaffPick))]
[JsonSerializable(typeof(List<ViewStaffPick>))]
[JsonSerializable(typeof(StaffPickForm))]
[JsonSerializable(typeof(ViewAdminActionsPage))]
[JsonSerializable(typeof(UsersIds))]
[JsonSerializable(typeof(ModerateAccountForm))]
[JsonSerializable(typeof(RemovePostForm))]
[JsonSerializable(typeof(ResolveReportForm))]
[JsonSerializable(typeof(ViewModeratedAccount))]
[JsonSerializable(typeof(ViewModeratedAccountsPage))]
[JsonSerializable(typeof(ViewAdminRoot))]
[JsonSerializable(typeof(ViewAdminRootsPage))]
[JsonSerializable(typeof(RootRoleForm))]
[JsonSerializable(typeof(AdminPasswordForm))]
[JsonSerializable(typeof(DeletePersonaForm))]
[JsonSerializable(typeof(DeleteGroupForm))]
[JsonSerializable(typeof(ViewLocalEmoji))]
[JsonSerializable(typeof(List<ViewLocalEmoji>))]
public partial class ClientJsonContext : JsonSerializerContext public partial class ClientJsonContext : JsonSerializerContext
{ {
} }
+3 -4
View File
@@ -2,7 +2,6 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Services; using decePubClient.Services;
@@ -22,13 +21,13 @@ namespace decePubClient.Helpers
/// <summary> /// <summary>
/// The JSON options for /clientapi payloads and localStorage: web defaults, enums by name as PrivaPub writes them /// The JSON options for /clientapi payloads and localStorage: web defaults, enums by name as PrivaPub writes them
/// (numbers still read), ClientJsonContext first, reflection after. /// (numbers still read), ClientJsonContext first, then PrivaPub.ClientModels' own context.
/// </summary> /// </summary>
public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web) public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }, Converters = { new JsonStringEnumConverter() },
TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver()) TypeInfoResolverChain = { ClientJsonContext.Default, ClientModelsJsonContext.Default, UnregisteredJson.Instance }
}; };
/// <summary>The JSON options for PrivaPub's Mastodon API and /oauth: snake_case, MastodonJsonContext first.</summary> /// <summary>The JSON options for PrivaPub's Mastodon API and /oauth: snake_case, MastodonJsonContext first.</summary>
@@ -37,7 +36,7 @@ namespace decePubClient.Helpers
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true, IgnoreReadOnlyProperties = true,
TypeInfoResolver = JsonTypeInfoResolver.Combine(MastodonJsonContext.Default, new DefaultJsonTypeInfoResolver()) TypeInfoResolverChain = { MastodonJsonContext.Default, UnregisteredJson.Instance }
}; };
/// <summary>What a cancelled request turns into: HTTP 410, which AddResponseError ignores.</summary> /// <summary>What a cancelled request turns into: HTTP 410, which AddResponseError ignores.</summary>
+48
View File
@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Helpers
{
public sealed class UnregisteredJson : IJsonTypeInfoResolver
{
public static readonly UnregisteredJson Instance = new();
static readonly DefaultJsonTypeInfoResolver Reflection = new();
static readonly ConcurrentDictionary<Type, byte> Reported = new();
public static bool Strict { get; set; }
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
if (Strict)
throw new NotSupportedException($"{type.FullName} is not registered in a JsonSerializerContext (HR0306).");
if (Reported.TryAdd(type, 0))
Console.Error.WriteLine($"{type.FullName} is serialized without a JsonSerializerContext registration (HR0306)");
return Reflection.GetTypeInfo(type, options);
}
public static void Check(bool strict)
{
Strict = strict;
if (!strict)
return;
var missing = Missing(ClientModelsJsonContext.Default, typeof(WebResult), "PrivaPub.ClientModels")
.Concat(Missing(MastodonJsonContext.Default, typeof(Status), "decePubClient.Models.Mastodon"))
.ToList();
if (missing.Count > 0)
throw new InvalidOperationException("Not registered in a JsonSerializerContext (HR0306): " + string.Join(", ", missing));
}
static IEnumerable<string> Missing(IJsonTypeInfoResolver context, Type anchor, string space) =>
anchor.Assembly.GetTypes()
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && t.Namespace is { } ns && ns.StartsWith(space, StringComparison.Ordinal)
&& !ns.EndsWith(".Resources", StringComparison.Ordinal) && !typeof(Attribute).IsAssignableFrom(t) && !typeof(JsonSerializerContext).IsAssignableFrom(t))
.Where(t => context.GetTypeInfo(t, new JsonSerializerOptions()) == null)
.Select(t => t.FullName);
}
}
+1
View File
@@ -16,6 +16,7 @@ namespace decePubClient;
[JsonSerializable(typeof(StatusContext))] [JsonSerializable(typeof(StatusContext))]
[JsonSerializable(typeof(Account))] [JsonSerializable(typeof(Account))]
[JsonSerializable(typeof(List<Account>))] [JsonSerializable(typeof(List<Account>))]
[JsonSerializable(typeof(AccountPage))]
[JsonSerializable(typeof(Relationship))] [JsonSerializable(typeof(Relationship))]
[JsonSerializable(typeof(List<Relationship>))] [JsonSerializable(typeof(List<Relationship>))]
[JsonSerializable(typeof(List<string>))] [JsonSerializable(typeof(List<string>))]
+1
View File
@@ -86,5 +86,6 @@ builder.Services.AddSingleton(serviceProvider =>
}); });
var host = builder.Build(); var host = builder.Build();
UnregisteredJson.Check(!builder.HostEnvironment.IsProduction());
await host.SetDefaultCulture(); await host.SetDefaultCulture();
await host.RunAsync(); await host.RunAsync();