diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs
index 68fa514e..1f47a176 100644
--- a/src/PostIt.Tests/BearerScopeTests.cs
+++ b/src/PostIt.Tests/BearerScopeTests.cs
@@ -8,6 +8,9 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Yavsc.Blogspot;
+using Yavsc.Api.Client;
+using PostIt.Services;
using PostIt.Services;
using Xunit;
@@ -119,7 +122,7 @@ public class BearerScopeTests
// Resolve a BlogApiClient on top. We don't need real
// posts; we just need the outbound HTTP request to be
// the one we capture.
- var blog = new BlogApiClient(subClient);
+ var blog = new BlogApiClient(subClient, "http://localhost/");
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs
index cea3e83f..350a71f1 100644
--- a/src/PostIt.Tests/MainPageSaveTests.cs
+++ b/src/PostIt.Tests/MainPageSaveTests.cs
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
+using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
@@ -40,7 +41,7 @@ public class MainPageSaveTests
// not a Control, so it needs a navigation host).
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
- var blog = new BlogApiClient(api);
+ var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel };
diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs
index d8de8025..b964a18e 100644
--- a/src/PostIt.Tests/PostItViewModelTests.cs
+++ b/src/PostIt.Tests/PostItViewModelTests.cs
@@ -1,4 +1,5 @@
using Yavsc.Blogspot;
+using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels;
@@ -14,7 +15,7 @@ public class PostItViewModelTests
// default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient();
- var blog = new BlogApiClient(fakeApi);
+ var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
@@ -46,7 +47,7 @@ public class PostItViewModelTests
new() { Id = 2, Title = "World" }
};
var api = new StubYavscApiClient(expected);
- var blog = new BlogApiClient(api);
+ var blog = new BlogApiClient(api, "http://localhost/");
var posts = await blog.GetPostsAsync();
diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs
index c020fec9..e54bc541 100644
--- a/src/PostIt.Tests/YavscApiClientTests.cs
+++ b/src/PostIt.Tests/YavscApiClientTests.cs
@@ -8,6 +8,9 @@ using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
+using Yavsc.Blogspot;
+using Yavsc.Api.Client;
+using PostIt.Services;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index b5740f2f..4a250ebe 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -7,6 +7,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using PostIt.Services;
+using Yavsc.Api.Client;
using PostIt.ViewModels;
using PostIt.Views;
@@ -55,7 +56,7 @@ public partial class App : Application
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
- var client = new BlogApiClient(api);
+ var client = new BlogApiClient(api, settings.BlogsApiUrl);
var services = new ServiceCollection();
diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
index e8024d7d..a9864db0 100644
--- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot;
+using Yavsc.Api.Client;
using PostIt.Services;
namespace PostIt.ViewModels;
diff --git a/src/Yavsc.Api.Client/BlogAclApiClient.cs b/src/Yavsc.Api.Client/BlogAclApiClient.cs
new file mode 100644
index 00000000..71263ca4
--- /dev/null
+++ b/src/Yavsc.Api.Client/BlogAclApiClient.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Api.Client.Dtos;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for /api/blogacl on the Yavsc Blogs server.
+///
+/// Each grants a single
+/// Circle access to a single BlogPost. The server
+/// scopes every endpoint to the caller's uid: only the author of
+/// the underlying blog post can list, create, modify, or delete
+/// its ACL entries.
+///
+public sealed class BlogAclApiClient
+{
+ private const string Path = "blogacl";
+
+ private readonly IYavscApiClient _api;
+
+ public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrEmpty(blogsBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
+
+ if (api.Http.BaseAddress is null)
+ api.Http.BaseAddress = new Uri(blogsBaseAddress);
+ }
+
+ public Task> GetMyAclAsync(CancellationToken ct = default)
+ => _api.CallAsync>(HttpMethod.Get, Path, ct: ct);
+
+ public Task GetAclAsync(long circleId, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
+
+ public Task GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Post, Path, body: acl, ct: ct);
+
+ public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
+
+ public Task RevokeAsync(long circleId, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct);
+}
diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs
similarity index 78%
rename from src/PostIt/PostIt/Services/BlogApiClient.cs
rename to src/Yavsc.Api.Client/BlogApiClient.cs
index f2061927..537124a7 100644
--- a/src/PostIt/PostIt/Services/BlogApiClient.cs
+++ b/src/Yavsc.Api.Client/BlogApiClient.cs
@@ -5,15 +5,16 @@ using System.Threading;
using System.Threading.Tasks;
using Yavsc.Blogspot;
-namespace PostIt.Services;
+namespace Yavsc.Api.Client;
///
/// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at https://blogs.pschneider.fr). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to
-/// . This class is a thin DTO↔path
-/// mapper, nothing more.
+/// , which lives in the consuming
+/// application (PostIt). This class is a thin DTO↔path mapper,
+/// nothing more.
///
/// URL convention. 's
/// BaseAddress already terminates with /api/v1/
@@ -34,16 +35,20 @@ public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "blog";
- private readonly YavscApiClient _api;
+ private readonly IYavscApiClient _api;
+ private readonly Uri _baseAddress;
private readonly string _pathPrefix;
- public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
+ public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrEmpty(blogsBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
- // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
+ // e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
- api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl);
+ _baseAddress = new Uri(blogsBaseAddress);
+ api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
diff --git a/src/Yavsc.Api.Client/CircleApiClient.cs b/src/Yavsc.Api.Client/CircleApiClient.cs
new file mode 100644
index 00000000..a8b04a40
--- /dev/null
+++ b/src/Yavsc.Api.Client/CircleApiClient.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Api.Client.Dtos;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for /api/circle on the Yavsc Blogs server.
+///
+/// Same conventions as : all
+/// transport is delegated to ; this
+/// class only maps paths to DTOs.
+///
+/// The server now (since the BlogAcl fix on this branch)
+/// scopes every read and write to the caller's uid. There is no
+/// way for the client to read or modify another user's circles
+/// — the route will return 404 (not 403) when the circle exists
+/// but belongs to someone else, to avoid leaking its existence.
+///
+public sealed class CircleApiClient
+{
+ private const string Path = "circle";
+
+ private readonly IYavscApiClient _api;
+
+ public CircleApiClient(IYavscApiClient api, string blogsBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrEmpty(blogsBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
+
+ if (api.Http.BaseAddress is null)
+ api.Http.BaseAddress = new Uri(blogsBaseAddress);
+ }
+
+ public Task> GetMyCirclesAsync(CancellationToken ct = default)
+ => _api.CallAsync>(HttpMethod.Get, Path, ct: ct);
+
+ public Task GetCircleAsync(long id, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Get, $"{Path}/{id}", ct: ct);
+
+ public Task CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Post, Path, body: circle, ct: ct);
+
+ public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct);
+
+ public Task DeleteCircleAsync(long id, CancellationToken ct = default)
+ => _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
+}
diff --git a/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs
new file mode 100644
index 00000000..f5d1e50e
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs
@@ -0,0 +1,19 @@
+namespace Yavsc.Api.Client.Dtos;
+
+///
+/// Wire format for GET /api/blogacl and friends.
+///
+/// The server-side
+/// Yavsc.Models.Access.CircleAuthorizationToBlogPost EF entity
+/// carries virtual navigation properties (Target,
+/// Allowed) that pull in the full BlogPost and Circle graphs.
+/// The client never needs them: when showing the ACL of a post, the
+/// UI already has the post, and the circles are looked up by id
+/// against the list returned by GET /api/circle.
+///
+public sealed class CircleAuthorizationDto
+{
+ public long CircleId { get; set; }
+ public long BlogPostId { get; set; }
+ public bool Comment { get; set; }
+}
diff --git a/src/Yavsc.Api.Client/Dtos/CircleDto.cs b/src/Yavsc.Api.Client/Dtos/CircleDto.cs
new file mode 100644
index 00000000..ed6980e2
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/CircleDto.cs
@@ -0,0 +1,23 @@
+namespace Yavsc.Api.Client.Dtos;
+
+///
+/// Wire format for GET /api/circle and friends.
+///
+/// Field names match the JSON the server emits (camelCase via
+/// the default policy), so no
+/// [JsonPropertyName] attributes are required.
+///
+/// Mirrors the server-side Yavsc.Models.Relationship.Circle
+/// EF entity but stops short of the navigation properties
+/// (Owner, Members) which depend on
+/// ApplicationUser and other server-only types. The client
+/// only ever needs the id, name, and owner of a circle to drive
+/// the UI.
+///
+public sealed class CircleDto
+{
+ public long Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string OwnerId { get; set; } = string.Empty;
+ public bool Public { get; set; }
+}