refactor(core): Native AOT - JSON source generators, AOT-safe models and API client

- Add NozJsonContext / NozJsonContextPretty source generator contexts
- Replace all reflection-based JSON with source-generated overloads
- Add NotesCacheFile record for persistent note cache
- Add date field to NoteInfo, CancellationToken to all API methods
- LocalConfig uses source-generated serialization (compact + indented)
This commit is contained in:
kryptomrx 2026-05-16 23:20:21 +02:00
parent 765ff7a976
commit b76896dc7a
6 changed files with 61 additions and 38 deletions

View file

@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using NozCli.Core.Config; using NozCli.Core.Config;
using NozCli.Core.Models; using NozCli.Core.Models;
using NozCli.Core.Serialization;
namespace NozCli.Core.Client; namespace NozCli.Core.Client;
@ -16,16 +17,15 @@ public class NozApiClient : IDisposable
_http = new HttpClient _http = new HttpClient
{ {
BaseAddress = new Uri(cfg.ServerUrl.TrimEnd('/') + "/"), BaseAddress = new Uri(cfg.ServerUrl.TrimEnd('/') + "/"),
// Enforce TLS — reject plain HTTP at the OS level
DefaultRequestVersion = new Version(2, 0), DefaultRequestVersion = new Version(2, 0),
}; };
_http.DefaultRequestHeaders.Authorization = _http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", cfg.Token); new AuthenticationHeaderValue("Bearer", cfg.Token);
} }
public async Task<ServerCapabilities> GetConfigAsync() public async Task<ServerCapabilities> GetConfigAsync(CancellationToken ct = default)
{ {
var res = await _http.GetFromJsonAsync<JsonElement>("api/cli/config"); var res = await _http.GetFromJsonAsync("api/cli/config", NozJsonContext.Default.JsonElement, ct);
return new ServerCapabilities( return new ServerCapabilities(
Server: res.GetProperty("server").GetString()!, Server: res.GetProperty("server").GetString()!,
Enabled: res.GetProperty("enabled").GetBoolean(), Enabled: res.GetProperty("enabled").GetBoolean(),
@ -36,9 +36,9 @@ public class NozApiClient : IDisposable
); );
} }
public async Task<List<NoteInfo>> GetNotesAsync() public async Task<List<NoteInfo>> GetNotesAsync(CancellationToken ct = default)
{ {
var res = await _http.GetFromJsonAsync<JsonElement>("api/cli/notes"); var res = await _http.GetFromJsonAsync("api/cli/notes", NozJsonContext.Default.JsonElement, ct);
var notes = res.GetProperty("notes"); var notes = res.GetProperty("notes");
return notes.EnumerateArray().Select(n => new NoteInfo( return notes.EnumerateArray().Select(n => new NoteInfo(
Slug: n.GetProperty("slug").GetString()!, Slug: n.GetProperty("slug").GetString()!,
@ -47,13 +47,14 @@ public class NozApiClient : IDisposable
Topic: n.GetProperty("topic").GetString()!, Topic: n.GetProperty("topic").GetString()!,
Lang: n.GetProperty("lang").GetString()!, Lang: n.GetProperty("lang").GetString()!,
Private: n.GetProperty("private").GetBoolean(), Private: n.GetProperty("private").GetBoolean(),
Hash: n.TryGetProperty("hash", out var h) ? h.GetString() : null Hash: n.TryGetProperty("hash", out var h) ? h.GetString() : null,
Date: n.TryGetProperty("date", out var d) ? d.GetString() : null
)).ToList(); )).ToList();
} }
public async Task<NoteContent> GetNoteAsync(string slug) public async Task<NoteContent> GetNoteAsync(string slug, CancellationToken ct = default)
{ {
var res = await _http.GetFromJsonAsync<JsonElement>($"api/cli/notes/{slug}"); var res = await _http.GetFromJsonAsync($"api/cli/notes/{slug}", NozJsonContext.Default.JsonElement, ct);
return new NoteContent( return new NoteContent(
Slug: res.GetProperty("slug").GetString()!, Slug: res.GetProperty("slug").GetString()!,
Markdown: res.GetProperty("markdown").GetString()!, Markdown: res.GetProperty("markdown").GetString()!,
@ -61,25 +62,25 @@ public class NozApiClient : IDisposable
); );
} }
public async Task<string> PutNoteAsync(string slug, string markdown) public async Task<string> PutNoteAsync(string slug, string markdown, CancellationToken ct = default)
{ {
var body = JsonSerializer.Serialize(new { markdown }); var body = JsonSerializer.Serialize(new PushBody(markdown), NozJsonContext.Default.PushBody);
var content = new StringContent(body, Encoding.UTF8, "application/json"); var content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await _http.PutAsync($"api/cli/notes/{slug}", content); var res = await _http.PutAsync($"api/cli/notes/{slug}", content, ct);
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync<JsonElement>(); var json = await res.Content.ReadFromJsonAsync(NozJsonContext.Default.JsonElement, ct);
return json.GetProperty("hash").GetString()!; return json.GetProperty("hash").GetString()!;
} }
public async Task DeleteNoteAsync(string slug) public async Task DeleteNoteAsync(string slug, CancellationToken ct = default)
{ {
var res = await _http.DeleteAsync($"api/cli/notes/{slug}"); var res = await _http.DeleteAsync($"api/cli/notes/{slug}", ct);
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
public async Task RebuildIndexAsync() public async Task RebuildIndexAsync(CancellationToken ct = default)
{ {
var res = await _http.PostAsync("api/cli/rebuild", null); var res = await _http.PostAsync("api/cli/rebuild", null, ct);
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }

View file

@ -1,13 +1,17 @@
using System.Text.Json; using System.Text.Json;
using NozCli.Core.Serialization;
namespace NozCli.Core.Config; namespace NozCli.Core.Config;
public class LocalConfig public class LocalConfig
{ {
public string ServerUrl { get; set; } = string.Empty; public string ServerUrl { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
/// Default editor (nano, vim, vi, notepad …) — used by 'edit' and 'new'
public string? Editor { get; set; } = null;
/// Editor for the -c flag (code, zed, kate …) — used by 'edit -c' and 'new -c'
public string? CodeEditor { get; set; } = null;
// ~/.config/noz/config.json
private static readonly string ConfigPath = Path.Combine( private static readonly string ConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "noz", "config.json" ".config", "noz", "config.json"
@ -17,24 +21,17 @@ public class LocalConfig
{ {
if (!File.Exists(ConfigPath)) return null; if (!File.Exists(ConfigPath)) return null;
var json = File.ReadAllText(ConfigPath); var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<LocalConfig>(json, JsonOptions); return JsonSerializer.Deserialize(json, NozJsonContext.Default.LocalConfig);
} }
public void Save() public void Save()
{ {
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!);
// 0600 — only owner can read var json = JsonSerializer.Serialize(this, NozJsonContextPretty.Default.LocalConfig);
var json = JsonSerializer.Serialize(this, JsonOptions);
File.WriteAllText(ConfigPath, json); File.WriteAllText(ConfigPath, json);
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(ConfigPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); File.SetUnixFileMode(ConfigPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
} }
public static bool Exists() => File.Exists(ConfigPath); public static bool Exists() => File.Exists(ConfigPath);
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
} }

View file

@ -1,13 +1,14 @@
namespace NozCli.Core.Models; namespace NozCli.Core.Models;
public record NoteInfo( public record NoteInfo(
string Slug, string Slug,
string Title, string Title,
string Status, string Status,
string Topic, string Topic,
string Lang, string Lang,
bool Private, bool Private,
string? Hash string? Hash,
string? Date = null
); );
public record NoteContent( public record NoteContent(

View file

@ -0,0 +1,6 @@
namespace NozCli.Core.Models;
public record NotesCacheFile(
DateTimeOffset LastSynced,
List<NoteInfo> Notes
);

View file

@ -6,8 +6,5 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.8" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,21 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using NozCli.Core.Config;
using NozCli.Core.Models;
namespace NozCli.Core.Serialization;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonSerializable(typeof(LocalConfig))]
[JsonSerializable(typeof(PushBody))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(NotesCacheFile))]
[JsonSerializable(typeof(List<NoteInfo>))]
[JsonSerializable(typeof(NoteInfo))]
public partial class NozJsonContext : JsonSerializerContext { }
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
[JsonSerializable(typeof(LocalConfig))]
internal partial class NozJsonContextPretty : JsonSerializerContext { }
public record PushBody(string Markdown);