diff --git a/CLAUDE.md b/CLAUDE.md index e522749..e1ffe67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (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/`, - `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` (multipart), built once per attempt. - 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 @@ -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` (`Extensions/GenericExtensions.cs`). No inline format strings for display. -**JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads -`pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case). +**JSON (HR0306).** Every type that crosses HTTP or is stored has source-generated metadata: +- `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:** 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 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` 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. 4. Wire the page with early returns, toasts and/or an `ErrorViewer`. diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 8ed34f4..9ea15ce 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -2,20 +2,13 @@ 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; /// /// 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 -/// missing from it still work through the reflection fallback. Names stay camelCase: wwwroot/js/theme.js reads -/// PublicCacheData.pageSettings straight from localStorage. +/// Blazored.LocalStorage (Program.cs); PrivaPub.ClientModels' types come from ClientModelsJsonContext. A type missing +/// from both fails in Development and the pasture, and is reported on the console elsewhere. Names stay camelCase: +/// wwwroot/js/theme.js reads PublicCacheData.pageSettings straight from localStorage. /// [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, IgnoreReadOnlyProperties = true)] @@ -30,96 +23,9 @@ namespace decePubClient; [JsonSerializable(typeof(PushDevice))] [JsonSerializable(typeof(UploadMedia))] [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 [JsonSerializable(typeof(string))] [JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(JwtUser))] -[JsonSerializable(typeof(ViewAvatar))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(InsertAvatarForm))] -[JsonSerializable(typeof(ViewPersonaPreferences))] -[JsonSerializable(typeof(PersonaPreferencesForm))] -[JsonSerializable(typeof(ViewGroup))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(ViewGroupMember))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(InsertGroupForm))] -[JsonSerializable(typeof(UpdateGroupForm))] -[JsonSerializable(typeof(JoinGroupForm))] -[JsonSerializable(typeof(GroupMembershipForm))] -[JsonSerializable(typeof(ViewPost))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(InsertPostForm))] -[JsonSerializable(typeof(DeletePostForm))] -[JsonSerializable(typeof(ViewReport))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(DomainBlockForm))] -[JsonSerializable(typeof(ViewDomainBlock))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(ViewStatisticsOverview))] -[JsonSerializable(typeof(ViewHostsPage))] -[JsonSerializable(typeof(ViewCrawler))] -[JsonSerializable(typeof(ViewHostDetail))] -[JsonSerializable(typeof(ViewHostInstance))] -[JsonSerializable(typeof(ViewHostDay))] -[JsonSerializable(typeof(ViewInteractionEvent))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(ViewServerDay))] -[JsonSerializable(typeof(List))] -[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))] -[JsonSerializable(typeof(AnnouncementForm))] -[JsonSerializable(typeof(ViewRelay))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(RelayForm))] -[JsonSerializable(typeof(ViewStaffPick))] -[JsonSerializable(typeof(List))] -[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))] public partial class ClientJsonContext : JsonSerializerContext { } diff --git a/Helpers/SUtility.cs b/Helpers/SUtility.cs index 5f4d45a..452a69f 100644 --- a/Helpers/SUtility.cs +++ b/Helpers/SUtility.cs @@ -2,7 +2,6 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; using decePubClient.Models; using decePubClient.Services; @@ -22,13 +21,13 @@ namespace decePubClient.Helpers /// /// 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. /// public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() }, - TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver()) + TypeInfoResolverChain = { ClientJsonContext.Default, ClientModelsJsonContext.Default, UnregisteredJson.Instance } }; /// The JSON options for PrivaPub's Mastodon API and /oauth: snake_case, MastodonJsonContext first. @@ -37,7 +36,7 @@ namespace decePubClient.Helpers PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, IgnoreReadOnlyProperties = true, - TypeInfoResolver = JsonTypeInfoResolver.Combine(MastodonJsonContext.Default, new DefaultJsonTypeInfoResolver()) + TypeInfoResolverChain = { MastodonJsonContext.Default, UnregisteredJson.Instance } }; /// What a cancelled request turns into: HTTP 410, which AddResponseError ignores. diff --git a/Helpers/UnregisteredJson.cs b/Helpers/UnregisteredJson.cs new file mode 100644 index 0000000..978cf79 --- /dev/null +++ b/Helpers/UnregisteredJson.cs @@ -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 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 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); + } +} diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index 75ee14a..4fe1c0c 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -16,6 +16,7 @@ namespace decePubClient; [JsonSerializable(typeof(StatusContext))] [JsonSerializable(typeof(Account))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AccountPage))] [JsonSerializable(typeof(Relationship))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] diff --git a/Program.cs b/Program.cs index 9f5ccb0..5c273f9 100644 --- a/Program.cs +++ b/Program.cs @@ -86,5 +86,6 @@ builder.Services.AddSingleton(serviceProvider => }); var host = builder.Build(); +UnregisteredJson.Check(!builder.HostEnvironment.IsProduction()); await host.SetDefaultCulture(); await host.RunAsync(); \ No newline at end of file