using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Blogspot;
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
/// , 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/
/// (see Settings.ApiUrl). The path prefix below is
/// therefore relative to that version segment: a prefix of
/// "blog" resolves to …/api/v1/blog, which matches
/// the [Route(APIPrefix + "/blog")] attribute on
/// Yavsc.Blogs.Controllers.BlogApiController. Do not
/// re-include the api/ segment here — that produced 404s
/// in the past (see commit "PostIt: fix blog API double-prefix").
///
/// The class is intentionally non-IDisposable: it does not own the
/// it depends on. Lifetimes are managed
/// by the consumer (typically a singleton service registered with
/// the application).
///
public sealed class BlogApiClient
{
private const string DefaultPathPrefix = "blogspot";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly string _pathPrefix;
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));
// e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
_baseAddress = new Uri(blogsBaseAddress);
api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
}
public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync>(
HttpMethod.Get,
$"{_pathPrefix}?start={start}&take={take}",
ct: ct);
public Task GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task CreatePostAsync(
BlogPostDto post,
IReadOnlyCollection? files = null,
CancellationToken ct = default)
=> SendPostAsync(HttpMethod.Post, _pathPrefix, post, files, ct);
public Task UpdatePostAsync(
long id,
BlogPostDto post,
IReadOnlyCollection? files = null,
CancellationToken ct = default)
=> SendPostAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", post, files, ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
///
/// Set a post's publication state. true publishes
/// it (visible to anonymous readers via
/// PermissionHandler.IsPublic); false takes
/// it back to draft. Idempotent: the resulting state
/// matches the call, regardless of the previous state.
///
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
private Task SendPostAsync(
HttpMethod method,
string path,
BlogPostDto post,
IReadOnlyCollection? files,
CancellationToken ct)
{
if (files is null || files.Count == 0)
return _api.CallAsync(method, path, body: post, ct: ct);
return _api.CallAsync(method, path, () => CreateMultipartContent(post, files), ct: ct);
}
private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files)
{
var content = new MultipartFormDataContent();
var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
});
content.Add(new StringContent(blogJson), "blog");
foreach (var file in files)
{
var fileContent = new ByteArrayContent(file.Content);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
content.Add(fileContent, "file", file.FileName);
}
return content;
}
}