release/1.0.7 #34
11 changed files with 171 additions and 12 deletions
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will consume to manage blog posts, circles, and per-post ACLs. Clients in this commit: - BlogApiClient (moved from PostIt/Services; same public surface, now depends on IYavscApiClient instead of the concrete class). - CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes the blogs base URL explicitly in its constructor so it doesn't need to know about PostIt's Settings type. - BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl. Same conventions as CircleApiClient. DTOs (Yavsc.Api.Client.Dtos): - CircleDto: id, name, ownerId, public. Stops short of the navigation properties on the server-side Circle (Owner, Members), which depend on ApplicationUser and other server types we don't want to drag into the client. - CircleAuthorizationDto: circleId, blogPostId, comment. Same reason: the server entity has Target and Allowed navigation properties the client never needs. The clients now require the caller to pass the blogs base URL explicitly in the constructor (previously the BlogApiClient sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that field is PostIt-specific). The one production call site (App.axaml.cs) and four test call sites are updated to pass the URL. Build + 51/51 tests green. The IYavscApiClient abstraction was landed in the previous commit so this one could be a pure addition + relocation.
commit
f835ad42a1
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
src/Yavsc.Api.Client/BlogAclApiClient.cs
Normal file
49
src/Yavsc.Api.Client/BlogAclApiClient.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
|
||||
///
|
||||
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single
|
||||
/// <c>Circle</c> access to a single <c>BlogPost</c>. 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.</para>
|
||||
/// </summary>
|
||||
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<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct);
|
||||
|
||||
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
|
||||
|
||||
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleAuthorizationDto?>(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);
|
||||
}
|
||||
|
|
@ -5,15 +5,16 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Services;
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// High-level client for the Blog subsystem of the Yavsc API
|
||||
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
|
||||
/// concerns — base URL, JSON serialisation, Bearer auth, silent
|
||||
/// refresh on 401, request body shaping — are delegated to
|
||||
/// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
|
||||
/// mapper, nothing more.
|
||||
/// <see cref="YavscApiClient"/>, which lives in the consuming
|
||||
/// application (PostIt). This class is a thin DTO↔path mapper,
|
||||
/// nothing more.
|
||||
///
|
||||
/// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s
|
||||
/// <c>BaseAddress</c> already terminates with <c>/api/v1/</c>
|
||||
|
|
@ -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;
|
||||
}
|
||||
53
src/Yavsc.Api.Client/CircleApiClient.cs
Normal file
53
src/Yavsc.Api.Client/CircleApiClient.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
|
||||
///
|
||||
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
|
||||
/// transport is delegated to <see cref="YavscApiClient"/>; this
|
||||
/// class only maps paths to DTOs.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
|
||||
|
||||
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
|
||||
|
||||
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
|
||||
=> _api.CallAsync<CircleDto?>(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);
|
||||
}
|
||||
19
src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs
Normal file
19
src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
namespace Yavsc.Api.Client.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for <c>GET /api/blogacl</c> and friends.
|
||||
///
|
||||
/// <para>The server-side
|
||||
/// <c>Yavsc.Models.Access.CircleAuthorizationToBlogPost</c> EF entity
|
||||
/// carries virtual navigation properties (<c>Target</c>,
|
||||
/// <c>Allowed</c>) 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 <c>GET /api/circle</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class CircleAuthorizationDto
|
||||
{
|
||||
public long CircleId { get; set; }
|
||||
public long BlogPostId { get; set; }
|
||||
public bool Comment { get; set; }
|
||||
}
|
||||
23
src/Yavsc.Api.Client/Dtos/CircleDto.cs
Normal file
23
src/Yavsc.Api.Client/Dtos/CircleDto.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
namespace Yavsc.Api.Client.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for <c>GET /api/circle</c> and friends.
|
||||
///
|
||||
/// <para>Field names match the JSON the server emits (camelCase via
|
||||
/// the default <see cref="System.Text.Json"/> policy), so no
|
||||
/// <c>[JsonPropertyName]</c> attributes are required.</para>
|
||||
///
|
||||
/// <para>Mirrors the server-side <c>Yavsc.Models.Relationship.Circle</c>
|
||||
/// EF entity but stops short of the navigation properties
|
||||
/// (<c>Owner</c>, <c>Members</c>) which depend on
|
||||
/// <c>ApplicationUser</c> and other server-only types. The client
|
||||
/// only ever needs the id, name, and owner of a circle to drive
|
||||
/// the UI.</para>
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue