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,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using NozCli.Core.Serialization;
namespace NozCli.Core.Config; namespace NozCli.Core.Config;
@ -6,8 +7,11 @@ 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

@ -7,7 +7,8 @@ public record NoteInfo(
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);