From f674d059fdbe1d300b8f5320feaa15632e5d5350 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 28 May 2024 17:09:50 +0200 Subject: [PATCH] add UserRendererService and improve errors --- .editorconfig | 2 + .idea/.idea.Foxnouns.NET/.idea/.gitignore | 1 + .../Controllers/UsersController.cs | 5 +- .../Database/DatabaseQueryExtensions.cs | 4 +- .../20240528145744_AddListHidden.Designer.cs | 474 ++++++++++++++++++ .../20240528145744_AddListHidden.cs | 29 ++ .../DatabaseContextModelSnapshot.cs | 4 + Foxnouns.Backend/Database/Models/User.cs | 1 + Foxnouns.Backend/ExpectedError.cs | 23 +- .../Extensions/WebApplicationExtensions.cs | 9 +- .../Middleware/ErrorHandlerMiddleware.cs | 14 +- Foxnouns.Backend/Program.cs | 12 +- .../Services/MemberRendererService.cs | 18 + .../Services/UserRendererService.cs | 36 ++ 14 files changed, 607 insertions(+), 25 deletions(-) create mode 100644 .editorconfig create mode 100644 Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.Designer.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs create mode 100644 Foxnouns.Backend/Services/MemberRendererService.cs create mode 100644 Foxnouns.Backend/Services/UserRendererService.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2a1f655 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none diff --git a/.idea/.idea.Foxnouns.NET/.idea/.gitignore b/.idea/.idea.Foxnouns.NET/.idea/.gitignore index 0542aff..1b7de0c 100644 --- a/.idea/.idea.Foxnouns.NET/.idea/.gitignore +++ b/.idea/.idea.Foxnouns.NET/.idea/.gitignore @@ -11,3 +11,4 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +discord.xml diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 9c5ecea..62d5a3c 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,18 +1,19 @@ using System.Diagnostics; using Foxnouns.Backend.Database; using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users")] -public class UsersController(DatabaseContext db) : ApiControllerBase +public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase { [HttpGet("{userRef}")] public async Task GetUser(string userRef) { var user = await db.ResolveUserAsync(userRef); - return Ok(user); + return Ok(await userRendererService.RenderUserAsync(user)); } [HttpGet("@me")] diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index d3411ba..fcd7f83 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -13,16 +13,14 @@ public static class DatabaseQueryExtensions if (Snowflake.TryParse(userRef, out var snowflake)) { user = await context.Users - .Include(u => u.Members) .FirstOrDefaultAsync(u => u.Id == snowflake); if (user != null) return user; } user = await context.Users - .Include(u => u.Members) .FirstOrDefaultAsync(u => u.Username == userRef); if (user != null) return user; - throw new FoxnounsError.UnknownEntityError(typeof(User)); + throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); } public static async Task GetFrontendApplicationAsync(this DatabaseContext context) diff --git a/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.Designer.cs new file mode 100644 index 0000000..07d8181 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.Designer.cs @@ -0,0 +1,474 @@ +// +using Foxnouns.Backend.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240528145744_AddListHidden")] + partial class AddListHidden + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_applications"); + + b.ToTable("applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthType") + .HasColumnType("integer") + .HasColumnName("auth_type"); + + b.Property("FediverseApplicationId") + .HasColumnType("bigint") + .HasColumnName("fediverse_application_id"); + + b.Property("RemoteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_id"); + + b.Property("RemoteUsername") + .HasColumnType("text") + .HasColumnName("remote_username"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_methods"); + + b.HasIndex("FediverseApplicationId") + .HasDatabaseName("ix_auth_methods_fediverse_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_methods_user_id"); + + b.ToTable("auth_methods", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("InstanceType") + .HasColumnType("integer") + .HasColumnName("instance_type"); + + b.HasKey("Id") + .HasName("pk_fediverse_applications"); + + b.ToTable("fediverse_applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Unlisted") + .HasColumnType("boolean") + .HasColumnName("unlisted"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_members"); + + b.HasIndex("UserId", "Name") + .IsUnique() + .HasDatabaseName("ix_members_user_id_name"); + + b.ToTable("members", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ApplicationId") + .HasColumnType("bigint") + .HasColumnName("application_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("ManuallyExpired") + .HasColumnType("boolean") + .HasColumnName("manually_expired"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("ix_tokens_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tokens_user_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("ListHidden") + .HasColumnType("boolean") + .HasColumnName("list_hidden"); + + b.Property("MemberTitle") + .HasColumnType("text") + .HasColumnName("member_title"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") + .WithMany() + .HasForeignKey("FediverseApplicationId") + .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("AuthMethods") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_methods_users_user_id"); + + b.Navigation("FediverseApplication"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("Members") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_members_users_user_id"); + + b.OwnsOne("System.Collections.Generic.List", "Fields", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("fields"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.OwnsOne("System.Collections.Generic.List", "Names", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("names"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.OwnsOne("System.Collections.Generic.List", "Pronouns", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("pronouns"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.Navigation("Fields") + .IsRequired(); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("Pronouns") + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_applications_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_users_user_id"); + + b.Navigation("Application"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("fields"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("names"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("pronouns"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.Navigation("Fields") + .IsRequired(); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("Pronouns") + .IsRequired(); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Navigation("AuthMethods"); + + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs new file mode 100644 index 0000000..1b40552 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddListHidden : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "list_hidden", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "list_hidden", + table: "users"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 8d69ff0..f90a3c3 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -242,6 +242,10 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("text[]") .HasColumnName("links"); + b.Property("ListHidden") + .HasColumnType("boolean") + .HasColumnName("list_hidden"); + b.Property("MemberTitle") .HasColumnType("text") .HasColumnName("member_title"); diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 9821bde..1d5f852 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -8,6 +8,7 @@ public class User : BaseModel public string? MemberTitle { get; set; } public string? Avatar { get; set; } public string[] Links { get; set; } = []; + public bool ListHidden { get; set; } public List Names { get; set; } = []; public List Pronouns { get; set; } = []; diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 9a1cc32..3ff84fc 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -15,9 +15,11 @@ public class FoxnounsError(string message, Exception? inner = null) : Exception( : DatabaseError($"Entity of type {entityType.Name} not found", inner); } -public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxnounsError(message) +public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCode? errorCode = null) + : FoxnounsError(message) { public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; + public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError; public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized); @@ -30,8 +32,6 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxno public class BadRequest(string message, ModelStateDictionary? modelState = null) : ApiError(message, statusCode: HttpStatusCode.BadRequest) { - public readonly ModelStateDictionary? Errors = modelState; - public JObject ToJson() { var o = new JObject @@ -39,10 +39,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxno { "status", (int)HttpStatusCode.BadRequest }, { "code", ErrorCode.BadRequest.ToString() } }; - if (Errors == null) return o; + if (modelState == null) return o; var a = new JArray(); - foreach (var error in Errors.Where(e => e.Value is { Errors.Count: > 0 })) + foreach (var error in modelState.Where(e => e.Value is { Errors.Count: > 0 })) { var errorObj = new JObject { @@ -61,7 +61,18 @@ public class ApiError(string message, HttpStatusCode? statusCode = null) : Foxno } } - public class NotFound(string message) : ApiError(message, statusCode: HttpStatusCode.NotFound); + public class NotFound(string message, ErrorCode? code = null) + : ApiError(message, statusCode: HttpStatusCode.NotFound, errorCode: code); public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); +} + +public enum ErrorCode +{ + InternalServerError, + Forbidden, + BadRequest, + AuthenticationError, + GenericApiError, + UserNotFound, } \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index a457f45..4bcb43f 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -1,5 +1,6 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; using Microsoft.EntityFrameworkCore; using NodaTime; using Serilog; @@ -12,13 +13,13 @@ public static class WebApplicationExtensions /// /// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls. /// - public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder, LogEventLevel level) + public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder) { var config = builder.Configuration.Get() ?? new(); var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Is(level) + .MinimumLevel.Is(config.LogEventLevel) // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) @@ -62,7 +63,9 @@ public static class WebApplicationExtensions public static IServiceCollection AddCustomServices(this IServiceCollection services) => services .AddSingleton(SystemClock.Instance) - .AddSnowflakeGenerator(); + .AddSnowflakeGenerator() + .AddScoped() + .AddScoped(); public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services .AddScoped() diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs index e9c46ec..da4804b 100644 --- a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -45,7 +45,7 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError { Status = (int)ae.StatusCode, - Code = ErrorCode.GenericApiError, + Code = ae.ErrorCode, Message = ae.Message, })); return; @@ -76,18 +76,12 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware public record HttpApiError { public required int Status { get; init; } + [JsonConverter(typeof(StringEnumConverter))] public required ErrorCode Code { get; init; } + public required string Message { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string[]? Scopes { get; init; } -} - -public enum ErrorCode -{ - InternalServerError, - Forbidden, - BadRequest, - AuthenticationError, - GenericApiError, } \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index c63f8bc..4eca8cb 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -3,13 +3,14 @@ using Foxnouns.Backend.Database; using Serilog; using Foxnouns.Backend.Extensions; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using Newtonsoft.Json.Serialization; var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration(); -builder.AddSerilog(config.LogEventLevel); +builder.AddSerilog(); builder.Services .AddControllers() @@ -25,6 +26,15 @@ builder.Services ); }); +// Set the default converter to snake case as we use it in a couple places. +JsonConvert.DefaultSettings = () => new JsonSerializerSettings +{ + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } +}; + builder.Services .AddDbContext() .AddCustomServices() diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs new file mode 100644 index 0000000..73d1998 --- /dev/null +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -0,0 +1,18 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; + +namespace Foxnouns.Backend.Services; + +public class MemberRendererService(DatabaseContext db) +{ + public PartialMember RenderPartialMember(Member member) => new(member.Id, member.Name, + member.DisplayName, member.Bio, member.Names, member.Pronouns); + + public record PartialMember( + Snowflake Id, + string Name, + string? DisplayName, + string? Bio, + IEnumerable Names, + IEnumerable Pronouns); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs new file mode 100644 index 0000000..0f22021 --- /dev/null +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -0,0 +1,36 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace Foxnouns.Backend.Services; + +public class UserRendererService(DatabaseContext db, MemberRendererService memberRendererService) +{ + public async Task RenderUserAsync(User user, User? selfUser = null, bool renderMembers = true) + { + renderMembers = renderMembers && (!user.ListHidden || selfUser?.Id == user.Id); + + var members = renderMembers ? await db.Members.Where(m => m.UserId == user.Id).ToListAsync() : []; + + return new UserResponse( + user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, user.Avatar, user.Links, user.Names, + user.Pronouns, user.Fields, + renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null); + } + + public record UserResponse( + Snowflake Id, + string Username, + string? DisplayName, + string? Bio, + string? MemberTitle, + string? AvatarUrl, + string[] Links, + IEnumerable Names, + IEnumerable Pronouns, + IEnumerable Fields, + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? Members + ); +} \ No newline at end of file