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; } +}