Add documentation to dotnet client.

This commit is contained in:
Sebastian Jeltsch
2025-01-15 16:57:10 +01:00
parent 6b65b5d616
commit c719d8708b
10 changed files with 250 additions and 22 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

+4
View File
@@ -1,2 +1,6 @@
bin/
obj/
# docfx documentation.
_site/
api/
+98 -13
View File
@@ -7,67 +7,124 @@ using System.Text.Json;
namespace TrailBase;
/// <summary>
/// Representation of User JSON objects.
/// </summary>
public class User {
/// <summary>Auth subject, i.e. user id.</summary>
public string sub { get; }
/// <summary>The user's email address.</summary>
public string email { get; }
/// <summary>
/// User constructor.
/// </summary>
/// <param name="sub">Auth subject, i.e. user id.</param>
/// <param name="email">User's email address</param>
public User(string sub, string email) {
this.sub = sub;
this.email = email;
}
}
/// <summary>
/// Representation of Credentials JSON objects used for log in.
/// </summary>
public class Credentials {
/// <summary>The user's email address.</summary>
public string email { get; }
/// <summary>The user's password.</summary>
public string password { get; }
/// <summary>
/// Credentials constructor.
/// </summary>
/// <param name="email">User's email address</param>
/// <param name="password">User's password</param>
public Credentials(string email, string password) {
this.email = email;
this.password = password;
}
}
public class RefreshToken {
/// <summary>
/// Representation of RefreshTokenRequest JSON objects.
/// </summary>
public class RefreshTokenRequest {
/// <summary>The refresh token received at login.</summary>
public string refresh_token { get; }
public RefreshToken(string refreshToken) {
/// <summary>
/// RefreshTokenRequest constructor.
/// </summary>
/// <param name="refreshToken">The refresh token.</param>
public RefreshTokenRequest(string refreshToken) {
refresh_token = refreshToken;
}
}
public class TokenResponse {
/// <summary>
/// Representation of RefreshTokenResponse JSON objects provided on refresh.
/// </summary>
public class RefreshTokenResponse {
/// <summary>New auth token in exchange for the refresh token.</summary>
public string auth_token { get; }
/// <summary>Cross-site request forgery token.</summary>
public string? csrf_token { get; }
public TokenResponse(string authToken, string? csrfToken) {
/// <summary>
/// RefreshTokenResponse constructor.
/// </summary>
/// <param name="authToken">User authentication token.</param>
/// <param name="csrfToken">User Cross-site request forgery token.</param>
public RefreshTokenResponse(string authToken, string? csrfToken) {
auth_token = authToken;
csrf_token = csrfToken;
}
}
/// <summary>
/// Representation of Token JSON objects provided on login.
/// </summary>
public class Tokens {
/// <summary>User auth token.</summary>
public string auth_token { get; }
/// <summary>User refresh token for future auth token exchanges.</summary>
public string? refresh_token { get; }
/// <summary>Cross-site request forgery token.</summary>
public string? csrf_token { get; }
/// <summary>
/// Tokens constructor.
/// </summary>
public Tokens(string auth_token, string? refresh_token, string? csrf_token) {
this.auth_token = auth_token;
this.refresh_token = refresh_token;
this.csrf_token = csrf_token;
}
/// <summary>Serialize Tokens.</summary>
public override string ToString() {
return $"Tokens({auth_token}, {refresh_token}, {csrf_token})";
}
}
/// <summary>
/// Representation of JwtToken JSON objects.
/// </summary>
public class JwtToken {
/// <summary>Auth subject, i.e. user id.</summary>
public string sub { get; }
/// <summary>JWT token issue timestamp.</summary>
public long iat { get; }
/// <summary>Expiration timestamp.</summary>
public long exp { get; }
/// <summary>User's email address.</summary>
public string email { get; }
/// <summary>Cross-site request forgery token.</summary>
public string csrf_token { get; }
/// <summary>JwtToken constructor.</summary>
[JsonConstructor]
public JwtToken(
string sub,
@@ -88,8 +145,8 @@ public class JwtToken {
[JsonSerializable(typeof(Credentials))]
[JsonSerializable(typeof(JwtToken))]
[JsonSerializable(typeof(Tokens))]
[JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(RefreshToken))]
[JsonSerializable(typeof(RefreshTokenResponse))]
[JsonSerializable(typeof(RefreshTokenRequest))]
[JsonSerializable(typeof(User))]
internal partial class SourceGenerationContext : JsonSerializerContext {
}
@@ -111,7 +168,10 @@ class TokenState {
var json = jwtToken.Payload.SerializeToJson();
return new TokenState(
(tokens, JsonSerializer.Deserialize<JwtToken>(json, SourceGenerationContext.Default.JwtToken))!,
(
tokens,
JsonSerializer.Deserialize<JwtToken>(json, SourceGenerationContext.Default.JwtToken)
)!,
buildHeaders(tokens)
);
}
@@ -139,7 +199,7 @@ class TokenState {
}
}
class ThinClient {
internal class ThinClient {
static readonly HttpClient client = new HttpClient();
string site;
@@ -188,21 +248,33 @@ class ThinClient {
}
}
/// <summary>
/// The main API for interacting with TrailBase servers.
/// </summary>
public class Client {
static readonly string _authApi = "api/auth/v1";
static readonly ILogger logger = LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger("TrailBase.Client");
static readonly ILogger logger = LoggerFactory.Create(
builder => builder.AddConsole()).CreateLogger("TrailBase.Client");
ThinClient client;
/// <summary>Site this Client is connected to.</summary>
public string site { get; }
TokenState tokenState;
/// <summary>
/// Construct a TrailBase Client for the given [site] and [tokens].
/// </summary>
/// <param name="site">Site URL, e.g. https://trailbase.io:4000.</param>
/// <param name="tokens">Optional tokens for a given authenticated user</param>
public Client(String site, Tokens? tokens) {
client = new ThinClient(site);
this.site = site;
tokenState = TokenState.build(tokens);
}
/// <summary>Get tokens of the logged-in user if any.</summary>
public Tokens? Tokens() => tokenState.state?.Item1;
/// <summary>Get the logged-in user if any.</summary>
public User? User() {
var authToken = Tokens()?.auth_token;
if (authToken != null) {
@@ -215,10 +287,12 @@ public class Client {
return null;
}
/// <summary>Construct a record API object for the API with the given name.</summary>
public RecordApi Records(string name) {
return new RecordApi(this, name);
}
/// <summary>Log in with the given credentials.</summary>
public async Task<Tokens> Login(string email, string password) {
var response = await Fetch(
$"{_authApi}/login",
@@ -233,12 +307,16 @@ public class Client {
return tokens;
}
/// <summary>Log out the current user.</summary>
public async Task<bool> Logout() {
var refreshToken = tokenState.state?.Item1.refresh_token;
try {
if (refreshToken != null) {
var tokenJson = JsonContent.Create(new RefreshToken(refreshToken), SourceGenerationContext.Default.RefreshToken);
var tokenJson = JsonContent.Create(
new RefreshTokenRequest(refreshToken),
SourceGenerationContext.Default.RefreshTokenRequest
);
await Fetch($"{_authApi}/logout", HttpMethod.Post, tokenJson, null);
}
else {
@@ -280,6 +358,7 @@ public class Client {
return ts;
}
/// <summary>Refresh the current auth token.</summary>
public async Task RefreshAuthToken() {
var refreshToken = shouldRefresh(tokenState);
if (refreshToken != null) {
@@ -291,13 +370,19 @@ public class Client {
var response = await client.Fetch(
$"{_authApi}/refresh",
tokenState,
JsonContent.Create(new RefreshToken(refreshToken), SourceGenerationContext.Default.RefreshToken),
JsonContent.Create(
new RefreshTokenRequest(refreshToken),
SourceGenerationContext.Default.RefreshTokenRequest
),
HttpMethod.Post,
null
);
string json = await response.Content.ReadAsStringAsync();
TokenResponse tokenResponse = JsonSerializer.Deserialize<TokenResponse>(json, SourceGenerationContext.Default.TokenResponse)!;
RefreshTokenResponse tokenResponse = JsonSerializer.Deserialize<RefreshTokenResponse>(
json,
SourceGenerationContext.Default.RefreshTokenResponse
)!;
return TokenState.build(new Tokens(
tokenResponse.auth_token,
@@ -306,7 +391,7 @@ public class Client {
));
}
public async Task<HttpResponseMessage> Fetch(
internal async Task<HttpResponseMessage> Fetch(
string path,
HttpMethod? method,
HttpContent? data,
+1 -1
View File
@@ -6,7 +6,7 @@ using System.Diagnostics.CodeAnalysis;
namespace TrailBase;
public static class Constants {
static class Constants {
public static int Port = 4010 + System.Environment.Version.Major;
}
+79 -8
View File
@@ -7,57 +7,78 @@ using System.Diagnostics.CodeAnalysis;
namespace TrailBase;
public class RecordId { }
/// <summary>Base for RecordId representations.</summary>
public abstract class RecordId {
/// <summary>Serialize RecordId.</summary>
public abstract override string ToString();
}
/// <summary>Un-typed record id.</summary>
public class ResponseRecordId : RecordId {
/// <summary>Serialized id, could be integer or UUID.</summary>
public string id { get; }
/// <summary>ResponseRecordId constructor.</summary>
public ResponseRecordId(string id) {
this.id = id;
}
/// <summary>Serialize RecordId.</summary>
public override string ToString() => id;
}
/// <summary>Integer record id.</summary>
public class IntegerRecordId : RecordId {
public long id { get; }
long id { get; }
/// <summary>Integer record id constructor.</summary>
public IntegerRecordId(long id) {
this.id = id;
}
/// <summary>Serialize RecordId.</summary>
public override string ToString() => id.ToString();
}
/// <summary>UUID record id.</summary>
public class UuidRecordId : RecordId {
public Guid id { get; }
Guid id { get; }
/// <summary>UUID record id constructor.</summary>
public UuidRecordId(Guid id) {
this.id = id;
}
/// <summary>UUID record id constructor.</summary>
public UuidRecordId(string id) {
var bytes = System.Convert.FromBase64String(id.Replace('-', '+').Replace('_', '/'));
this.id = new Guid(bytes);
}
/// <summary>Serialize UuidRecordId.</summary>
public override string ToString() {
var bytes = id.ToByteArray();
return System.Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_');
}
}
/// <summary>Pagination state representation.</summary>
public class Pagination {
/// <summary>Offset cursor.</summary>
public string? cursor { get; }
/// <summary>Limit of elements per page.</summary>
public int? limit { get; }
/// <summary>Pagination constructor.</summary>
public Pagination(string? cursor, int? limit) {
this.cursor = cursor;
this.limit = limit;
}
}
/// <summary>Realtime event for change subscriptions.</summary>
public abstract class Event {
/// <summary>Get associated record value as JSON object.</summary>
public abstract JsonNode? Value { get; }
internal static Event Parse(string message) {
@@ -88,44 +109,61 @@ public abstract class Event {
}
}
/// <summary>Record insertion event.</summary>
public class InsertEvent : Event {
/// <summary>Get associated record value as JSON object.</summary>
public override JsonNode? Value { get; }
/// <summary>InsertEvent constructor.</summary>
public InsertEvent(JsonNode? value) {
this.Value = value;
}
/// <summary>Serialize InsertEvent.</summary>
public override string ToString() => $"InsertEvent({Value})";
}
/// <summary>Record update event.</summary>
public class UpdateEvent : Event {
/// <summary>Get associated record value as JSON object.</summary>
public override JsonNode? Value { get; }
/// <summary>UpdateEvent constructor.</summary>
public UpdateEvent(JsonNode? value) {
this.Value = value;
}
/// <summary>Serialize UpdateEvent.</summary>
public override string ToString() => $"UpdateEvent({Value})";
}
/// <summary>Record deletion event.</summary>
public class DeleteEvent : Event {
/// <summary>Get associated record value as JSON object.</summary>
public override JsonNode? Value { get; }
/// <summary>DeleteEvent constructor.</summary>
public DeleteEvent(JsonNode? value) {
this.Value = value;
}
/// <summary>Serialize DeleteEvent.</summary>
public override string ToString() => $"DeleteEvent({Value})";
}
/// <summary>Error event.</summary>
public class ErrorEvent : Event {
/// <summary>Get associated record value as JSON object.</summary>
public override JsonNode? Value { get { return null; } }
/// <summary>Get associated error message.</summary>
public string ErrorMessage { get; }
/// <summary>ErrorEvent constructor.</summary>
public ErrorEvent(string errorMsg) {
this.ErrorMessage = errorMsg;
}
/// <summary>Serialize ErrorEvent.</summary>
public override string ToString() => $"ErrorEvent({ErrorMessage})";
}
@@ -134,6 +172,7 @@ public class ErrorEvent : Event {
internal partial class SerializeResponseRecordIdContext : JsonSerializerContext {
}
/// <summary>Main API to interact with Records.</summary>
public class RecordApi {
static readonly string _recordApi = "api/records/v1";
const string DynamicCodeMessage = "Use overload with JsonTypeInfo instead";
@@ -142,30 +181,38 @@ public class RecordApi {
Client client { get; }
string name { get; }
public RecordApi(Client client, string name) {
internal RecordApi(Client client, string name) {
this.client = client;
this.name = name;
}
/// <summary>Read the record with given id.</summary>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task<T?> Read<T>(RecordId id) {
string json = await (await ReadImpl(id)).ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json);
}
/// <summary>Read the record with given id.</summary>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task<T?> Read<T>(string id) => await Read<T>(new UuidRecordId(id));
/// <summary>Read the record with given id.</summary>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task<T?> Read<T>(long id) => await Read<T>(new IntegerRecordId(id));
/// <summary>Read the record with given id.</summary>
public async Task<T?> Read<T>(RecordId id, JsonTypeInfo<T> jsonTypeInfo) {
string json = await (await ReadImpl(id)).ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, jsonTypeInfo);
}
public async Task<T?> Read<T>(string id, JsonTypeInfo<T> jsonTypeInfo) => await Read<T>(new UuidRecordId(id), jsonTypeInfo);
public async Task<T?> Read<T>(long id, JsonTypeInfo<T> jsonTypeInfo) => await Read<T>(new IntegerRecordId(id), jsonTypeInfo);
/// <summary>Read the record with given id.</summary>
public async Task<T?> Read<T>(string id, JsonTypeInfo<T> jsonTypeInfo)
=> await Read<T>(new UuidRecordId(id), jsonTypeInfo);
/// <summary>Read the record with given id.</summary>
public async Task<T?> Read<T>(long id, JsonTypeInfo<T> jsonTypeInfo)
=> await Read<T>(new IntegerRecordId(id), jsonTypeInfo);
private async Task<HttpContent> ReadImpl(RecordId id) {
var response = await client.Fetch(
@@ -177,6 +224,7 @@ public class RecordApi {
return response.Content;
}
/// <summary>Create a new record with the given value.</summary>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task<RecordId> Create<T>(T record) {
@@ -187,6 +235,7 @@ public class RecordApi {
return await CreateImpl(recordJson);
}
/// <summary>Create a new record with the given value.</summary>
public async Task<RecordId> Create<T>(T record, JsonTypeInfo<T> jsonTypeInfo) {
var recordJson = JsonContent.Create(record, jsonTypeInfo, default);
return await CreateImpl(recordJson);
@@ -201,9 +250,18 @@ public class RecordApi {
);
string json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ResponseRecordId>(json, SerializeResponseRecordIdContext.Default.ResponseRecordId)!;
return JsonSerializer.Deserialize<ResponseRecordId>(
json,
SerializeResponseRecordIdContext.Default.ResponseRecordId
)!;
}
/// <summary>
/// List records.
/// </summary>
/// <param name="pagination">Pagination state.</param>
/// <param name="order">Sort results by the given columns in ascending/descending order, e.g. "-col_name".</param>
/// <param name="filters">Results filters, e.g. "col0[gte]=100".</param>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task<List<T>> List<T>(
@@ -215,10 +273,18 @@ public class RecordApi {
return JsonSerializer.Deserialize<List<T>>(json) ?? [];
}
/// <summary>
/// List records.
/// </summary>
/// <param name="pagination">Pagination state.</param>
/// <param name="order">Sort results by the given columns in ascending/descending order, e.g. "-col_name".</param>
/// <param name="filters">Results filters, e.g. "col0[gte]=100".</param>
/// <param name="jsonTypeInfo">Serialization type info for AOT mode.</param>
public async Task<List<T>> List<T>(
Pagination? pagination,
List<string>? order,
List<string>? filters, JsonTypeInfo<List<T>> jsonTypeInfo
List<string>? filters,
JsonTypeInfo<List<T>> jsonTypeInfo
) {
string json = await (await ListImpl(pagination, order, filters)).ReadAsStringAsync();
return JsonSerializer.Deserialize<List<T>>(json, jsonTypeInfo) ?? [];
@@ -268,6 +334,7 @@ public class RecordApi {
return response.Content;
}
/// <summary>Update record with the given id with the given values.</summary>
[RequiresDynamicCode(DynamicCodeMessage)]
[RequiresUnreferencedCode(UnreferencedCodeMessage)]
public async Task Update<T>(
@@ -281,6 +348,7 @@ public class RecordApi {
await UpdateImpl(id, recordJson);
}
/// <summary>Update record with the given id with the given values.</summary>
public async Task Update<T>(
RecordId id,
T record,
@@ -302,6 +370,7 @@ public class RecordApi {
);
}
/// <summary>Delete record with the given id.</summary>
public async Task Delete(RecordId id) {
var response = await client.Fetch(
$"{RecordApi._recordApi}/{name}/{id}",
@@ -311,11 +380,13 @@ public class RecordApi {
);
}
/// <summary>Listen for changes to record with given id.</summary>
public async Task<IAsyncEnumerable<Event>> Subscribe(RecordId id) {
var response = await SubscribeImpl(id.ToString()!);
return StreamToEnumerableImpl(await response.ReadAsStreamAsync());
}
/// <summary>Listen for all accessible changes to this Record API.</summary>
public async Task<IAsyncEnumerable<Event>> SubscribeAll() {
var response = await SubscribeImpl("*");
return StreamToEnumerableImpl(await response.ReadAsStreamAsync());
+2
View File
@@ -16,6 +16,8 @@
<!-- <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> -->
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"metadata": [
{
"src": [
{
"src": ".",
"files": [
"**/*.csproj"
],
"exclude": [
"test/**",
"_site/**",
"**/bin/**",
"**/obj/**"
]
}
],
"dest": "api"
}
],
"build": {
"content": [
{
"files": [
"**/*.{md,yml}"
],
"exclude": [
"_site/**"
]
}
],
"resource": [
{
"files": [
"images/**"
]
}
],
"output": "_site",
"template": [
"default",
"modern"
],
"globalMetadata": {
"_appName": "TrailBase",
"_appTitle": "TrailBase",
"_appLogoPath": "images/logo_48.svg",
"_enableSearch": true,
"pdf": false
}
}
}
+1
View File
@@ -0,0 +1 @@
../../../assets/logo_48.svg
+7
View File
@@ -0,0 +1,7 @@
---
_layout: landing
---
# TrailBase Client
Auto-generate reference documentation.
+2
View File
@@ -0,0 +1,2 @@
- name: API
href: api/