From d145ee98fb47ad492cb5d7364d70c41ddeb3fd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serkan=20S=CC=A7eker?= Date: Fri, 24 Jul 2026 15:20:36 +0300 Subject: [PATCH 1/2] feat: modernize and secure Palette Core API --- .config/dotnet-tools.json | 13 + PaletteCoreAPI.Tests/ApiSecurityTests.cs | 54 ++ PaletteCoreAPI.Tests/GlobalUsings.cs | 1 + PaletteCoreAPI.Tests/PaletteApiFactory.cs | 35 ++ .../PaletteCoreAPI.Tests.csproj | 30 ++ PaletteCoreAPI.sln | 6 + PaletteCoreAPI/Contracts/ResourceDtos.cs | 123 +++++ .../Controllers/ApikeysController.cs | 181 +++---- .../Controllers/CommentsController.cs | 122 +---- PaletteCoreAPI/Controllers/CrudController.cs | 102 ++++ .../Controllers/FavouritesController.cs | 120 +---- .../Controllers/FollowingsController.cs | 120 +---- .../Controllers/PostColorsController.cs | 123 +---- .../Controllers/PostTagsController.cs | 119 +---- PaletteCoreAPI/Controllers/PostsController.cs | 121 +---- PaletteCoreAPI/Controllers/TagsController.cs | 115 +--- .../Controllers/UserProfilesController.cs | 138 ++--- PaletteCoreAPI/Controllers/UsersController.cs | 174 +++--- .../Controllers/WeatherForecastController.cs | 39 -- .../20260724121559_InitialCreate.Designer.cs | 502 ++++++++++++++++++ .../20260724121559_InitialCreate.cs | 323 +++++++++++ .../Migrations/PaletteContextModelSnapshot.cs | 499 +++++++++++++++++ PaletteCoreAPI/Models/Apikeys.cs | 4 +- PaletteCoreAPI/Models/Comments.cs | 6 +- PaletteCoreAPI/Models/Favourites.cs | 4 +- PaletteCoreAPI/Models/Followings.cs | 4 +- PaletteCoreAPI/Models/PaletteContext.cs | 41 +- .../Models/PaletteContextFactory.cs | 25 + PaletteCoreAPI/Models/PostColors.cs | 2 +- PaletteCoreAPI/Models/PostTags.cs | 4 +- PaletteCoreAPI/Models/Posts.cs | 6 +- PaletteCoreAPI/Models/Tags.cs | 2 +- PaletteCoreAPI/Models/UserProfile.cs | 12 +- PaletteCoreAPI/Models/Users.cs | 4 +- PaletteCoreAPI/PaletteCoreAPI.csproj | 24 +- PaletteCoreAPI/Program.cs | 103 +++- PaletteCoreAPI/Properties/launchSettings.json | 6 +- PaletteCoreAPI/Startup.cs | 68 --- PaletteCoreAPI/WeatherForecast.cs | 15 - PaletteCoreAPI/appsettings.json | 11 +- README.md | 333 ++++-------- global.json | 6 + 42 files changed, 2330 insertions(+), 1410 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 PaletteCoreAPI.Tests/ApiSecurityTests.cs create mode 100644 PaletteCoreAPI.Tests/GlobalUsings.cs create mode 100644 PaletteCoreAPI.Tests/PaletteApiFactory.cs create mode 100644 PaletteCoreAPI.Tests/PaletteCoreAPI.Tests.csproj create mode 100644 PaletteCoreAPI/Contracts/ResourceDtos.cs create mode 100644 PaletteCoreAPI/Controllers/CrudController.cs delete mode 100644 PaletteCoreAPI/Controllers/WeatherForecastController.cs create mode 100644 PaletteCoreAPI/Migrations/20260724121559_InitialCreate.Designer.cs create mode 100644 PaletteCoreAPI/Migrations/20260724121559_InitialCreate.cs create mode 100644 PaletteCoreAPI/Migrations/PaletteContextModelSnapshot.cs create mode 100644 PaletteCoreAPI/Models/PaletteContextFactory.cs delete mode 100644 PaletteCoreAPI/Startup.cs delete mode 100644 PaletteCoreAPI/WeatherForecast.cs create mode 100644 global.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..f3cc8ba --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/PaletteCoreAPI.Tests/ApiSecurityTests.cs b/PaletteCoreAPI.Tests/ApiSecurityTests.cs new file mode 100644 index 0000000..d87dae2 --- /dev/null +++ b/PaletteCoreAPI.Tests/ApiSecurityTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using PaletteCoreAPI.Contracts; + +namespace PaletteCoreAPI.Tests; + +public sealed class ApiSecurityTests : IClassFixture +{ + private readonly PaletteApiFactory _factory; + + public ApiSecurityTests(PaletteApiFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Health_endpoint_is_public() + { + using var client = _factory.CreateClient(new() + { + BaseAddress = new Uri("https://localhost") + }); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("/api/posts")] + [InlineData("/api/users")] + [InlineData("/api/apikeys")] + public async Task Domain_endpoints_require_authentication(string path) + { + using var client = _factory.CreateClient(new() + { + BaseAddress = new Uri("https://localhost") + }); + + var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public void Public_contracts_do_not_expose_passwords_or_stored_api_keys() + { + var userProperties = typeof(UserDto).GetProperties().Select(item => item.Name); + var keyProperties = typeof(ApiKeyDto).GetProperties().Select(item => item.Name); + + Assert.DoesNotContain("Password", userProperties); + Assert.DoesNotContain("UserKey", keyProperties); + Assert.DoesNotContain("ApiKey", keyProperties); + } +} diff --git a/PaletteCoreAPI.Tests/GlobalUsings.cs b/PaletteCoreAPI.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/PaletteCoreAPI.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/PaletteCoreAPI.Tests/PaletteApiFactory.cs b/PaletteCoreAPI.Tests/PaletteApiFactory.cs new file mode 100644 index 0000000..1578ebd --- /dev/null +++ b/PaletteCoreAPI.Tests/PaletteApiFactory.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PaletteCoreAPI.Models; + +namespace PaletteCoreAPI.Tests; + +public sealed class PaletteApiFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Database"] = + "Server=localhost;Database=PaletteTests;Integrated Security=True;", + ["Authentication:Audience"] = "palette-api", + ["Authentication:Authority"] = "https://identity.example.test" + }); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"palette-tests-{Guid.NewGuid()}")); + }); + } +} diff --git a/PaletteCoreAPI.Tests/PaletteCoreAPI.Tests.csproj b/PaletteCoreAPI.Tests/PaletteCoreAPI.Tests.csproj new file mode 100644 index 0000000..6b4a5c6 --- /dev/null +++ b/PaletteCoreAPI.Tests/PaletteCoreAPI.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/PaletteCoreAPI.sln b/PaletteCoreAPI.sln index 5f2d35f..c07c3fa 100644 --- a/PaletteCoreAPI.sln +++ b/PaletteCoreAPI.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.30523.141 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteCoreAPI", "PaletteCoreAPI\PaletteCoreAPI.csproj", "{70B0EE8F-B4B8-40C1-B5F6-2300523FECCE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteCoreAPI.Tests", "PaletteCoreAPI.Tests\PaletteCoreAPI.Tests.csproj", "{B045F530-D77D-4F82-8A25-18BB81369F22}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {70B0EE8F-B4B8-40C1-B5F6-2300523FECCE}.Debug|Any CPU.Build.0 = Debug|Any CPU {70B0EE8F-B4B8-40C1-B5F6-2300523FECCE}.Release|Any CPU.ActiveCfg = Release|Any CPU {70B0EE8F-B4B8-40C1-B5F6-2300523FECCE}.Release|Any CPU.Build.0 = Release|Any CPU + {B045F530-D77D-4F82-8A25-18BB81369F22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B045F530-D77D-4F82-8A25-18BB81369F22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B045F530-D77D-4F82-8A25-18BB81369F22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B045F530-D77D-4F82-8A25-18BB81369F22}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PaletteCoreAPI/Contracts/ResourceDtos.cs b/PaletteCoreAPI/Contracts/ResourceDtos.cs new file mode 100644 index 0000000..9d88af4 --- /dev/null +++ b/PaletteCoreAPI/Contracts/ResourceDtos.cs @@ -0,0 +1,123 @@ +using System.ComponentModel.DataAnnotations; + +namespace PaletteCoreAPI.Contracts; + +public sealed record ApiKeyDto( + int ApiKeyId, + int UserId, + string Role, + DateTime CreatedAt); + +public sealed record ApiKeyCreateDto( + [property: Range(1, int.MaxValue)] int UserId, + [property: Required, MaxLength(50)] string Role); + +public sealed record ApiKeyCreatedDto( + int ApiKeyId, + int UserId, + string Role, + DateTime CreatedAt, + Guid ApiKey); + +public sealed record CommentDto( + int CommentId, + int CommenterId, + int PostId, + string Text, + DateTime CommentedAt); + +public sealed record CommentWriteDto( + [property: Range(1, int.MaxValue)] int CommenterId, + [property: Range(1, int.MaxValue)] int PostId, + [property: Required, MaxLength(50)] string Text); + +public sealed record FavouriteDto( + int FavouriteId, + int PostId, + int UserId, + DateTime CreatedAt); + +public sealed record FavouriteWriteDto( + [property: Range(1, int.MaxValue)] int PostId, + [property: Range(1, int.MaxValue)] int UserId); + +public sealed record FollowingDto( + int FollowingRelationshipId, + int UserId, + int FollowingId, + DateTime FollowedAt); + +public sealed record FollowingWriteDto( + [property: Range(1, int.MaxValue)] int UserId, + [property: Range(1, int.MaxValue)] int FollowingId); + +public sealed record PostColorDto( + int PostColorId, + int PostId, + int ColorHex1, + int ColorHex2, + int ColorHex3); + +public sealed record PostColorWriteDto( + [property: Range(1, int.MaxValue)] int PostId, + int ColorHex1, + int ColorHex2, + int ColorHex3); + +public sealed record PostTagDto( + int PostTagId, + int PostId, + int TagId); + +public sealed record PostTagWriteDto( + [property: Range(1, int.MaxValue)] int PostId, + [property: Range(1, int.MaxValue)] int TagId); + +public sealed record PostDto( + int PostId, + int UserId, + string Title, + string Contents, + DateTime PublishedAt); + +public sealed record PostWriteDto( + [property: Range(1, int.MaxValue)] int UserId, + [property: Required, MaxLength(50)] string Title, + [property: Required, MaxLength(50)] string Contents); + +public sealed record TagDto( + int TagId, + string Name); + +public sealed record TagWriteDto( + [property: Required, MaxLength(50)] string Name); + +public sealed record UserProfileDto( + int UserProfileId, + int UserId, + string FirstName, + string LastName, + string Email, + string About, + bool HasProfilePhoto); + +public sealed record UserProfileWriteDto( + [property: Range(1, int.MaxValue)] int UserId, + [property: Required, MaxLength(50)] string FirstName, + [property: Required, MaxLength(50)] string LastName, + [property: Required, EmailAddress, MaxLength(50)] string Email, + [property: Required, MaxLength(150)] string About, + byte[]? ProfilePhoto); + +public sealed record UserDto( + int UserId, + string Username, + DateTime RegisteredAt); + +public sealed record UserCreateDto( + [property: Required, MaxLength(50)] string Username, + [property: Required, MinLength(12), MaxLength(128)] string Password); + +public sealed record UserUpdateDto( + [property: Required, MaxLength(50)] string Username, + [property: MinLength(12), MaxLength(128)] string? Password); diff --git a/PaletteCoreAPI/Controllers/ApikeysController.cs b/PaletteCoreAPI/Controllers/ApikeysController.cs index a92b64f..3ed1d24 100644 --- a/PaletteCoreAPI/Controllers/ApikeysController.cs +++ b/PaletteCoreAPI/Controllers/ApikeysController.cs @@ -1,109 +1,112 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +[ApiController] +[Authorize(Policy = "admin")] +[Route("api/[controller]")] +public sealed class ApikeysController : ControllerBase { - [Route("api/[controller]")] - [ApiController] - public class ApikeysController : ControllerBase + private readonly PaletteContext _context; + + public ApikeysController(PaletteContext context) { - private readonly PaletteContext _context; + _context = context; + } - public ApikeysController(PaletteContext context) - { - _context = context; - } + [HttpGet] + public async Task>> GetAll( + CancellationToken cancellationToken) + { + var keys = await _context.Apikeys + .AsNoTracking() + .Select(key => new ApiKeyDto( + key.ApikeyId, + key.UserId, + key.Apirole, + key.KeyCreateDate)) + .ToListAsync(cancellationToken); + + return Ok(keys); + } - // GET: api/Apikeys - [HttpGet] - public async Task>> GetApikeys() - { - return await _context.Apikeys.ToListAsync(); - } + [HttpGet("{id:int}")] + public async Task> GetById( + int id, + CancellationToken cancellationToken) + { + var key = await _context.Apikeys + .AsNoTracking() + .Where(item => item.ApikeyId == id) + .Select(item => new ApiKeyDto( + item.ApikeyId, + item.UserId, + item.Apirole, + item.KeyCreateDate)) + .SingleOrDefaultAsync(cancellationToken); + + return key is null ? NotFound() : Ok(key); + } - // GET: api/Apikeys/5 - [HttpGet("{id}")] - public async Task> GetApikeys(int id) + [HttpPost] + public async Task> Create( + ApiKeyCreateDto request, + CancellationToken cancellationToken) + { + var entity = new Apikeys { - var apikeys = await _context.Apikeys.FindAsync(id); - - if (apikeys == null) - { - return NotFound(); - } - - return apikeys; - } + UserId = request.UserId, + Apirole = request.Role, + UserKey = Guid.NewGuid(), + KeyCreateDate = DateTime.UtcNow + }; + + _context.Apikeys.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + var response = new ApiKeyCreatedDto( + entity.ApikeyId, + entity.UserId, + entity.Apirole, + entity.KeyCreateDate, + entity.UserKey); + + return CreatedAtAction(nameof(GetById), new { id = entity.ApikeyId }, response); + } - // PUT: api/Apikeys/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutApikeys(int id, Apikeys apikeys) + [HttpPut("{id:int}")] + public async Task Update( + int id, + ApiKeyCreateDto request, + CancellationToken cancellationToken) + { + var key = await _context.Apikeys.FindAsync([id], cancellationToken); + if (key is null) { - if (id != apikeys.ApikeyId) - { - return BadRequest(); - } - - _context.Entry(apikeys).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!ApikeysExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); + return NotFound(); } - // POST: api/Apikeys - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostApikeys(Apikeys apikeys) - { - _context.Apikeys.Add(apikeys); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetApikeys", new { id = apikeys.ApikeyId }, apikeys); - } + key.UserId = request.UserId; + key.Apirole = request.Role; + await _context.SaveChangesAsync(cancellationToken); + return NoContent(); + } - // DELETE: api/Apikeys/5 - [HttpDelete("{id}")] - public async Task> DeleteApikeys(int id) + [HttpDelete("{id:int}")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + var key = await _context.Apikeys.FindAsync([id], cancellationToken); + if (key is null) { - var apikeys = await _context.Apikeys.FindAsync(id); - if (apikeys == null) - { - return NotFound(); - } - - _context.Apikeys.Remove(apikeys); - await _context.SaveChangesAsync(); - - return apikeys; + return NotFound(); } - private bool ApikeysExists(int id) - { - return _context.Apikeys.Any(e => e.ApikeyId == id); - } + _context.Apikeys.Remove(key); + await _context.SaveChangesAsync(cancellationToken); + return NoContent(); } } diff --git a/PaletteCoreAPI/Controllers/CommentsController.cs b/PaletteCoreAPI/Controllers/CommentsController.cs index ecb09d9..c143e4f 100644 --- a/PaletteCoreAPI/Controllers/CommentsController.cs +++ b/PaletteCoreAPI/Controllers/CommentsController.cs @@ -1,109 +1,33 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class CommentsController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class CommentsController : ControllerBase + public CommentsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public CommentsController(PaletteContext context) - { - _context = context; - } - - // GET: api/Comments - [HttpGet] - public async Task>> GetComments() - { - return await _context.Comments.ToListAsync(); - } - - // GET: api/Comments/5 - [HttpGet("{id}")] - public async Task> GetComments(int id) - { - var comments = await _context.Comments.FindAsync(id); - - if (comments == null) - { - return NotFound(); - } - - return comments; - } - - // PUT: api/Comments/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutComments(int id, Comments comments) - { - if (id != comments.CommentId) - { - return BadRequest(); - } - - _context.Entry(comments).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!CommentsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/Comments - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostComments(Comments comments) - { - _context.Comments.Add(comments); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetComments", new { id = comments.CommentId }, comments); - } - - // DELETE: api/Comments/5 - [HttpDelete("{id}")] - public async Task> DeleteComments(int id) - { - var comments = await _context.Comments.FindAsync(id); - if (comments == null) - { - return NotFound(); - } + } - _context.Comments.Remove(comments); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.Comments; + protected override int GetId(Comments entity) => entity.CommentId; + protected override CommentDto ToDto(Comments entity) => + new(entity.CommentId, entity.CommenterId, entity.PostId, entity.CommentText, entity.CommentedDate); - return comments; - } + protected override Comments CreateEntity(CommentWriteDto request) => new() + { + CommenterId = request.CommenterId, + PostId = request.PostId, + CommentText = request.Text, + CommentedDate = DateTime.UtcNow + }; - private bool CommentsExists(int id) - { - return _context.Comments.Any(e => e.CommentId == id); - } + protected override void ApplyUpdate(Comments entity, CommentWriteDto request) + { + entity.CommenterId = request.CommenterId; + entity.PostId = request.PostId; + entity.CommentText = request.Text; } } diff --git a/PaletteCoreAPI/Controllers/CrudController.cs b/PaletteCoreAPI/Controllers/CrudController.cs new file mode 100644 index 0000000..75f4d78 --- /dev/null +++ b/PaletteCoreAPI/Controllers/CrudController.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Models; + +namespace PaletteCoreAPI.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public abstract class CrudController : ControllerBase + where TEntity : class +{ + protected CrudController(PaletteContext context) + { + Context = context; + } + + protected PaletteContext Context { get; } + protected abstract DbSet Entities { get; } + protected abstract int GetId(TEntity entity); + protected abstract TDto ToDto(TEntity entity); + protected abstract TEntity CreateEntity(TWriteDto request); + protected abstract void ApplyUpdate(TEntity entity, TWriteDto request); + + protected virtual IQueryable ReadQuery() => Entities.AsNoTracking(); + + [HttpGet] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var entities = await ReadQuery().ToListAsync(cancellationToken); + return Ok(entities.Select(ToDto).ToList()); + } + + [HttpGet("{id:int}")] + public async Task> GetById(int id, CancellationToken cancellationToken) + { + var entity = await ReadQuery() + .SingleOrDefaultAsync( + item => EF.Property(item, IdPropertyName) == id, + cancellationToken); + + return entity is null ? NotFound() : Ok(ToDto(entity)); + } + + [HttpPost] + public async Task> Create( + TWriteDto request, + CancellationToken cancellationToken) + { + var entity = CreateEntity(request); + Entities.Add(entity); + await Context.SaveChangesAsync(cancellationToken); + + return CreatedAtAction(nameof(GetById), new { id = GetId(entity) }, ToDto(entity)); + } + + [HttpPut("{id:int}")] + public async Task Update( + int id, + TWriteDto request, + CancellationToken cancellationToken) + { + var entity = await Entities.FindAsync([id], cancellationToken); + if (entity is null) + { + return NotFound(); + } + + ApplyUpdate(entity, request); + await Context.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + var entity = await Entities.FindAsync([id], cancellationToken); + if (entity is null) + { + return NotFound(); + } + + Entities.Remove(entity); + await Context.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private string IdPropertyName => typeof(TEntity).Name switch + { + nameof(Comments) => nameof(Comments.CommentId), + nameof(Favourites) => nameof(Favourites.FavouriteId), + nameof(Followings) => nameof(Followings.FollowingsId), + nameof(PostColors) => nameof(PostColors.PostColorId), + nameof(PostTags) => nameof(PostTags.PostTagId), + nameof(Posts) => nameof(Posts.PostId), + nameof(Tags) => nameof(Tags.TagId), + nameof(UserProfile) => nameof(UserProfile.UserProfileId), + _ => throw new InvalidOperationException( + $"No primary-key property is registered for {typeof(TEntity).Name}.") + }; +} diff --git a/PaletteCoreAPI/Controllers/FavouritesController.cs b/PaletteCoreAPI/Controllers/FavouritesController.cs index 12ea71a..94873ac 100644 --- a/PaletteCoreAPI/Controllers/FavouritesController.cs +++ b/PaletteCoreAPI/Controllers/FavouritesController.cs @@ -1,109 +1,31 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class FavouritesController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class FavouritesController : ControllerBase + public FavouritesController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public FavouritesController(PaletteContext context) - { - _context = context; - } - - // GET: api/Favourites - [HttpGet] - public async Task>> GetFavourites() - { - return await _context.Favourites.ToListAsync(); - } - - // GET: api/Favourites/5 - [HttpGet("{id}")] - public async Task> GetFavourites(int id) - { - var favourites = await _context.Favourites.FindAsync(id); - - if (favourites == null) - { - return NotFound(); - } - - return favourites; - } - - // PUT: api/Favourites/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutFavourites(int id, Favourites favourites) - { - if (id != favourites.FavouriteId) - { - return BadRequest(); - } - - _context.Entry(favourites).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!FavouritesExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/Favourites - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostFavourites(Favourites favourites) - { - _context.Favourites.Add(favourites); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetFavourites", new { id = favourites.FavouriteId }, favourites); - } - - // DELETE: api/Favourites/5 - [HttpDelete("{id}")] - public async Task> DeleteFavourites(int id) - { - var favourites = await _context.Favourites.FindAsync(id); - if (favourites == null) - { - return NotFound(); - } + } - _context.Favourites.Remove(favourites); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.Favourites; + protected override int GetId(Favourites entity) => entity.FavouriteId; + protected override FavouriteDto ToDto(Favourites entity) => + new(entity.FavouriteId, entity.PostId, entity.UserId, entity.FavouriteDate); - return favourites; - } + protected override Favourites CreateEntity(FavouriteWriteDto request) => new() + { + PostId = request.PostId, + UserId = request.UserId, + FavouriteDate = DateTime.UtcNow + }; - private bool FavouritesExists(int id) - { - return _context.Favourites.Any(e => e.FavouriteId == id); - } + protected override void ApplyUpdate(Favourites entity, FavouriteWriteDto request) + { + entity.PostId = request.PostId; + entity.UserId = request.UserId; } } diff --git a/PaletteCoreAPI/Controllers/FollowingsController.cs b/PaletteCoreAPI/Controllers/FollowingsController.cs index 60a247c..56c10e4 100644 --- a/PaletteCoreAPI/Controllers/FollowingsController.cs +++ b/PaletteCoreAPI/Controllers/FollowingsController.cs @@ -1,109 +1,31 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class FollowingsController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class FollowingsController : ControllerBase + public FollowingsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public FollowingsController(PaletteContext context) - { - _context = context; - } - - // GET: api/Followings - [HttpGet] - public async Task>> GetFollowings() - { - return await _context.Followings.ToListAsync(); - } - - // GET: api/Followings/5 - [HttpGet("{id}")] - public async Task> GetFollowings(int id) - { - var followings = await _context.Followings.FindAsync(id); - - if (followings == null) - { - return NotFound(); - } - - return followings; - } - - // PUT: api/Followings/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutFollowings(int id, Followings followings) - { - if (id != followings.FollowingsId) - { - return BadRequest(); - } - - _context.Entry(followings).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!FollowingsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/Followings - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostFollowings(Followings followings) - { - _context.Followings.Add(followings); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetFollowings", new { id = followings.FollowingsId }, followings); - } - - // DELETE: api/Followings/5 - [HttpDelete("{id}")] - public async Task> DeleteFollowings(int id) - { - var followings = await _context.Followings.FindAsync(id); - if (followings == null) - { - return NotFound(); - } + } - _context.Followings.Remove(followings); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.Followings; + protected override int GetId(Followings entity) => entity.FollowingsId; + protected override FollowingDto ToDto(Followings entity) => + new(entity.FollowingsId, entity.UserId, entity.FollowingId, entity.FollowedDate); - return followings; - } + protected override Followings CreateEntity(FollowingWriteDto request) => new() + { + UserId = request.UserId, + FollowingId = request.FollowingId, + FollowedDate = DateTime.UtcNow + }; - private bool FollowingsExists(int id) - { - return _context.Followings.Any(e => e.FollowingsId == id); - } + protected override void ApplyUpdate(Followings entity, FollowingWriteDto request) + { + entity.UserId = request.UserId; + entity.FollowingId = request.FollowingId; } } diff --git a/PaletteCoreAPI/Controllers/PostColorsController.cs b/PaletteCoreAPI/Controllers/PostColorsController.cs index 57ababf..656d30a 100644 --- a/PaletteCoreAPI/Controllers/PostColorsController.cs +++ b/PaletteCoreAPI/Controllers/PostColorsController.cs @@ -1,109 +1,34 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class PostColorsController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class PostColorsController : ControllerBase + public PostColorsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public PostColorsController(PaletteContext context) - { - _context = context; - } - - // GET: api/PostColors - [HttpGet] - public async Task>> GetPostColors() - { - return await _context.PostColors.ToListAsync(); - } - - // GET: api/PostColors/5 - [HttpGet("{id}")] - public async Task> GetPostColors(int id) - { - var postColors = await _context.PostColors.FindAsync(id); - - if (postColors == null) - { - return NotFound(); - } - - return postColors; - } - - // PUT: api/PostColors/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutPostColors(int id, PostColors postColors) - { - if (id != postColors.PostColorId) - { - return BadRequest(); - } - - _context.Entry(postColors).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!PostColorsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/PostColors - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostPostColors(PostColors postColors) - { - _context.PostColors.Add(postColors); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetPostColors", new { id = postColors.PostColorId }, postColors); - } - - // DELETE: api/PostColors/5 - [HttpDelete("{id}")] - public async Task> DeletePostColors(int id) - { - var postColors = await _context.PostColors.FindAsync(id); - if (postColors == null) - { - return NotFound(); - } + } - _context.PostColors.Remove(postColors); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.PostColors; + protected override int GetId(PostColors entity) => entity.PostColorId; + protected override PostColorDto ToDto(PostColors entity) => + new(entity.PostColorId, entity.PostId, entity.ColorHex1, entity.ColorHex2, entity.ColorHex3); - return postColors; - } + protected override PostColors CreateEntity(PostColorWriteDto request) => new() + { + PostId = request.PostId, + ColorHex1 = request.ColorHex1, + ColorHex2 = request.ColorHex2, + ColorHex3 = request.ColorHex3 + }; - private bool PostColorsExists(int id) - { - return _context.PostColors.Any(e => e.PostColorId == id); - } + protected override void ApplyUpdate(PostColors entity, PostColorWriteDto request) + { + entity.PostId = request.PostId; + entity.ColorHex1 = request.ColorHex1; + entity.ColorHex2 = request.ColorHex2; + entity.ColorHex3 = request.ColorHex3; } } diff --git a/PaletteCoreAPI/Controllers/PostTagsController.cs b/PaletteCoreAPI/Controllers/PostTagsController.cs index 419beba..69e2fa7 100644 --- a/PaletteCoreAPI/Controllers/PostTagsController.cs +++ b/PaletteCoreAPI/Controllers/PostTagsController.cs @@ -1,109 +1,30 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class PostTagsController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class PostTagsController : ControllerBase + public PostTagsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public PostTagsController(PaletteContext context) - { - _context = context; - } - - // GET: api/PostTags - [HttpGet] - public async Task>> GetPostTags() - { - return await _context.PostTags.ToListAsync(); - } - - // GET: api/PostTags/5 - [HttpGet("{id}")] - public async Task> GetPostTags(int id) - { - var postTags = await _context.PostTags.FindAsync(id); - - if (postTags == null) - { - return NotFound(); - } - - return postTags; - } - - // PUT: api/PostTags/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutPostTags(int id, PostTags postTags) - { - if (id != postTags.PostTagId) - { - return BadRequest(); - } - - _context.Entry(postTags).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!PostTagsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/PostTags - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostPostTags(PostTags postTags) - { - _context.PostTags.Add(postTags); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetPostTags", new { id = postTags.PostTagId }, postTags); - } - - // DELETE: api/PostTags/5 - [HttpDelete("{id}")] - public async Task> DeletePostTags(int id) - { - var postTags = await _context.PostTags.FindAsync(id); - if (postTags == null) - { - return NotFound(); - } + } - _context.PostTags.Remove(postTags); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.PostTags; + protected override int GetId(PostTags entity) => entity.PostTagId; + protected override PostTagDto ToDto(PostTags entity) => + new(entity.PostTagId, entity.PostId, entity.TagId); - return postTags; - } + protected override PostTags CreateEntity(PostTagWriteDto request) => new() + { + PostId = request.PostId, + TagId = request.TagId + }; - private bool PostTagsExists(int id) - { - return _context.PostTags.Any(e => e.PostTagId == id); - } + protected override void ApplyUpdate(PostTags entity, PostTagWriteDto request) + { + entity.PostId = request.PostId; + entity.TagId = request.TagId; } } diff --git a/PaletteCoreAPI/Controllers/PostsController.cs b/PaletteCoreAPI/Controllers/PostsController.cs index a5236ce..5e45c27 100644 --- a/PaletteCoreAPI/Controllers/PostsController.cs +++ b/PaletteCoreAPI/Controllers/PostsController.cs @@ -1,109 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class PostsController : CrudController { - [Route("api/[controller]")] - [ApiController] - public class PostsController : ControllerBase + public PostsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public PostsController(PaletteContext context) - { - _context = context; - } - - // GET: api/Posts - [HttpGet] - public async Task>> GetPosts() - { - return await _context.Posts.ToListAsync(); - } - - // GET: api/Posts/5 - [HttpGet("{id}")] - public async Task> GetPosts(int id) - { - var posts = await _context.Posts.FindAsync(id); - - if (posts == null) - { - return NotFound(); - } - - return posts; - } - - // PUT: api/Posts/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutPosts(int id, Posts posts) - { - if (id != posts.PostId) - { - return BadRequest(); - } - - _context.Entry(posts).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!PostsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/Posts - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostPosts(Posts posts) - { - _context.Posts.Add(posts); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetPosts", new { id = posts.PostId }, posts); - } - - // DELETE: api/Posts/5 - [HttpDelete("{id}")] - public async Task> DeletePosts(int id) - { - var posts = await _context.Posts.FindAsync(id); - if (posts == null) - { - return NotFound(); - } + } - _context.Posts.Remove(posts); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.Posts; + protected override int GetId(Posts entity) => entity.PostId; + protected override PostDto ToDto(Posts entity) => + new(entity.PostId, entity.UserId, entity.Title, entity.Contents, entity.PublishedDate); - return posts; - } + protected override Posts CreateEntity(PostWriteDto request) => new() + { + UserId = request.UserId, + Title = request.Title, + Contents = request.Contents, + PublishedDate = DateTime.UtcNow + }; - private bool PostsExists(int id) - { - return _context.Posts.Any(e => e.PostId == id); - } + protected override void ApplyUpdate(Posts entity, PostWriteDto request) + { + entity.UserId = request.UserId; + entity.Title = request.Title; + entity.Contents = request.Contents; } } diff --git a/PaletteCoreAPI/Controllers/TagsController.cs b/PaletteCoreAPI/Controllers/TagsController.cs index 2fd40c8..bee067f 100644 --- a/PaletteCoreAPI/Controllers/TagsController.cs +++ b/PaletteCoreAPI/Controllers/TagsController.cs @@ -1,109 +1,26 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class TagsController : CrudController { - [Route("api/[controller]")] - [ApiController] - public class TagsController : ControllerBase + public TagsController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public TagsController(PaletteContext context) - { - _context = context; - } - - // GET: api/Tags - [HttpGet] - public async Task>> GetTags() - { - return await _context.Tags.ToListAsync(); - } - - // GET: api/Tags/5 - [HttpGet("{id}")] - public async Task> GetTags(int id) - { - var tags = await _context.Tags.FindAsync(id); - - if (tags == null) - { - return NotFound(); - } - - return tags; - } - - // PUT: api/Tags/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutTags(int id, Tags tags) - { - if (id != tags.TagId) - { - return BadRequest(); - } - - _context.Entry(tags).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!TagsExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/Tags - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostTags(Tags tags) - { - _context.Tags.Add(tags); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetTags", new { id = tags.TagId }, tags); - } - - // DELETE: api/Tags/5 - [HttpDelete("{id}")] - public async Task> DeleteTags(int id) - { - var tags = await _context.Tags.FindAsync(id); - if (tags == null) - { - return NotFound(); - } + } - _context.Tags.Remove(tags); - await _context.SaveChangesAsync(); + protected override DbSet Entities => Context.Tags; + protected override int GetId(Tags entity) => entity.TagId; + protected override TagDto ToDto(Tags entity) => new(entity.TagId, entity.Tag); - return tags; - } + protected override Tags CreateEntity(TagWriteDto request) => new() + { + Tag = request.Name + }; - private bool TagsExists(int id) - { - return _context.Tags.Any(e => e.TagId == id); - } + protected override void ApplyUpdate(Tags entity, TagWriteDto request) + { + entity.Tag = request.Name; } } diff --git a/PaletteCoreAPI/Controllers/UserProfilesController.cs b/PaletteCoreAPI/Controllers/UserProfilesController.cs index c5a41b9..96ad150 100644 --- a/PaletteCoreAPI/Controllers/UserProfilesController.cs +++ b/PaletteCoreAPI/Controllers/UserProfilesController.cs @@ -1,109 +1,45 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +public sealed class UserProfilesController + : CrudController { - [Route("api/[controller]")] - [ApiController] - public class UserProfilesController : ControllerBase + public UserProfilesController(PaletteContext context) : base(context) { - private readonly PaletteContext _context; - - public UserProfilesController(PaletteContext context) - { - _context = context; - } - - // GET: api/UserProfiles - [HttpGet] - public async Task>> GetUserProfile() - { - return await _context.UserProfile.ToListAsync(); - } - - // GET: api/UserProfiles/5 - [HttpGet("{id}")] - public async Task> GetUserProfile(int id) - { - var userProfile = await _context.UserProfile.FindAsync(id); - - if (userProfile == null) - { - return NotFound(); - } - - return userProfile; - } - - // PUT: api/UserProfiles/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutUserProfile(int id, UserProfile userProfile) - { - if (id != userProfile.UserProfileId) - { - return BadRequest(); - } - - _context.Entry(userProfile).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!UserProfileExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); - } - - // POST: api/UserProfiles - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostUserProfile(UserProfile userProfile) - { - _context.UserProfile.Add(userProfile); - await _context.SaveChangesAsync(); - - return CreatedAtAction("GetUserProfile", new { id = userProfile.UserProfileId }, userProfile); - } - - // DELETE: api/UserProfiles/5 - [HttpDelete("{id}")] - public async Task> DeleteUserProfile(int id) - { - var userProfile = await _context.UserProfile.FindAsync(id); - if (userProfile == null) - { - return NotFound(); - } - - _context.UserProfile.Remove(userProfile); - await _context.SaveChangesAsync(); - - return userProfile; - } + } - private bool UserProfileExists(int id) - { - return _context.UserProfile.Any(e => e.UserProfileId == id); - } + protected override DbSet Entities => Context.UserProfile; + protected override int GetId(UserProfile entity) => entity.UserProfileId; + protected override UserProfileDto ToDto(UserProfile entity) => + new( + entity.UserProfileId, + entity.UserId, + entity.FirstName, + entity.LastName, + entity.Email, + entity.About, + entity.ProfilePhoto is { Length: > 0 }); + + protected override UserProfile CreateEntity(UserProfileWriteDto request) => new() + { + UserId = request.UserId, + FirstName = request.FirstName, + LastName = request.LastName, + Email = request.Email, + About = request.About, + ProfilePhoto = request.ProfilePhoto + }; + + protected override void ApplyUpdate(UserProfile entity, UserProfileWriteDto request) + { + entity.UserId = request.UserId; + entity.FirstName = request.FirstName; + entity.LastName = request.LastName; + entity.Email = request.Email; + entity.About = request.About; + entity.ProfilePhoto = request.ProfilePhoto; } } diff --git a/PaletteCoreAPI/Controllers/UsersController.cs b/PaletteCoreAPI/Controllers/UsersController.cs index 0af5361..f7f785b 100644 --- a/PaletteCoreAPI/Controllers/UsersController.cs +++ b/PaletteCoreAPI/Controllers/UsersController.cs @@ -1,113 +1,115 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using PaletteCoreAPI.Contracts; using PaletteCoreAPI.Models; -namespace PaletteCoreAPI.Controllers +namespace PaletteCoreAPI.Controllers; + +[ApiController] +[Authorize(Policy = "admin")] +[Route("api/[controller]")] +public sealed class UsersController : ControllerBase { - [Route("api/[controller]")] - [ApiController] - public class UsersController : ControllerBase + private readonly PaletteContext _context; + private readonly IPasswordHasher _passwordHasher; + + public UsersController(PaletteContext context, IPasswordHasher passwordHasher) { - private readonly PaletteContext _context; + _context = context; + _passwordHasher = passwordHasher; + } - public UsersController(PaletteContext context) - { - _context = context; - } - /// - /// Get All Users from database. - /// - /// - // GET: api/Users - [HttpGet] - public async Task>> GetUsers() - { - return await _context.Users.ToListAsync(); - } + [HttpGet] + public async Task>> GetAll( + CancellationToken cancellationToken) + { + var users = await _context.Users + .AsNoTracking() + .Select(user => new UserDto(user.UserId, user.Username, user.RegisterDate)) + .ToListAsync(cancellationToken); - // GET: api/Users/5 - [HttpGet("{id}")] - public async Task> GetUsers(int id) - { - var users = await _context.Users.FindAsync(id); + return Ok(users); + } - if (users == null) - { - return NotFound(); - } + [HttpGet("{id:int}")] + public async Task> GetById( + int id, + CancellationToken cancellationToken) + { + var user = await _context.Users + .AsNoTracking() + .Where(item => item.UserId == id) + .Select(item => new UserDto(item.UserId, item.Username, item.RegisterDate)) + .SingleOrDefaultAsync(cancellationToken); - return users; - } + return user is null ? NotFound() : Ok(user); + } - // PUT: api/Users/5 - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPut("{id}")] - public async Task PutUsers(int id, Users users) + [HttpPost] + public async Task> Create( + UserCreateDto request, + CancellationToken cancellationToken) + { + if (await _context.Users.AnyAsync( + user => user.Username == request.Username, + cancellationToken)) { - if (id != users.UserId) + return Conflict(new ProblemDetails { - return BadRequest(); - } - - _context.Entry(users).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (!UsersExists(id)) - { - return NotFound(); - } - else - { - throw; - } - } - - return NoContent(); + Title = "Username already exists.", + Status = StatusCodes.Status409Conflict + }); } - // POST: api/Users - // To protect from overposting attacks, enable the specific properties you want to bind to, for - // more details, see https://go.microsoft.com/fwlink/?linkid=2123754. - [HttpPost] - public async Task> PostUsers(Users users) + var user = new Users { - _context.Users.Add(users); - await _context.SaveChangesAsync(); + Username = request.Username.Trim(), + RegisterDate = DateTime.UtcNow + }; + user.Password = _passwordHasher.HashPassword(user, request.Password); - return CreatedAtAction("GetUsers", new { id = users.UserId }, users); - } + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); - // DELETE: api/Users/5 - [HttpDelete("{id}")] - public async Task> DeleteUsers(int id) - { - var users = await _context.Users.FindAsync(id); - if (users == null) - { - return NotFound(); - } + var response = new UserDto(user.UserId, user.Username, user.RegisterDate); + return CreatedAtAction(nameof(GetById), new { id = user.UserId }, response); + } - _context.Users.Remove(users); - await _context.SaveChangesAsync(); + [HttpPut("{id:int}")] + public async Task Update( + int id, + UserUpdateDto request, + CancellationToken cancellationToken) + { + var user = await _context.Users.FindAsync([id], cancellationToken); + if (user is null) + { + return NotFound(); + } - return users; + user.Username = request.Username.Trim(); + if (!string.IsNullOrWhiteSpace(request.Password)) + { + user.Password = _passwordHasher.HashPassword(user, request.Password); } - private bool UsersExists(int id) + await _context.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + var user = await _context.Users.FindAsync([id], cancellationToken); + if (user is null) { - return _context.Users.Any(e => e.UserId == id); + return NotFound(); } + + _context.Users.Remove(user); + await _context.SaveChangesAsync(cancellationToken); + return NoContent(); } } diff --git a/PaletteCoreAPI/Controllers/WeatherForecastController.cs b/PaletteCoreAPI/Controllers/WeatherForecastController.cs deleted file mode 100644 index cf0c788..0000000 --- a/PaletteCoreAPI/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace PaletteCoreAPI.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet] - public IEnumerable Get() - { - var rng = new Random(); - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.Designer.cs b/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.Designer.cs new file mode 100644 index 0000000..8e40fb8 --- /dev/null +++ b/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.Designer.cs @@ -0,0 +1,502 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PaletteCoreAPI.Models; + +#nullable disable + +namespace PaletteCoreAPI.Migrations +{ + [DbContext(typeof(PaletteContext))] + [Migration("20260724121559_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PaletteCoreAPI.Models.Apikeys", b => + { + b.Property("ApikeyId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("APIKeyID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ApikeyId")); + + b.Property("Apirole") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("APIRole"); + + b.Property("KeyCreateDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.Property("UserKey") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("(newsequentialid())"); + + b.HasKey("ApikeyId") + .HasName("PK__APIKeys__C5971C76B3D13722"); + + b.HasIndex("UserId"); + + b.ToTable("APIKeys", (string)null); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Comments", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("CommentID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CommentId")); + + b.Property("CommentText") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CommentedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("CommenterId") + .HasColumnType("int") + .HasColumnName("CommenterID"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.HasKey("CommentId"); + + b.HasIndex("CommenterId"); + + b.HasIndex("PostId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Favourites", b => + { + b.Property("FavouriteId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("FavouriteID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("FavouriteId")); + + b.Property("FavouriteDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("FavouriteId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Favourites"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Followings", b => + { + b.Property("FollowingsId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("FollowingsID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("FollowingsId")); + + b.Property("FollowedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("FollowingId") + .HasColumnType("int") + .HasColumnName("FollowingID"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("FollowingsId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Followings"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostColors", b => + { + b.Property("PostColorId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostColorID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostColorId")); + + b.Property("ColorHex1") + .HasColumnType("int") + .HasColumnName("ColorHEX1"); + + b.Property("ColorHex2") + .HasColumnType("int") + .HasColumnName("ColorHEX2"); + + b.Property("ColorHex3") + .HasColumnType("int") + .HasColumnName("ColorHEX3"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.HasKey("PostColorId"); + + b.HasIndex("PostId"); + + b.ToTable("PostColors"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostTags", b => + { + b.Property("PostTagId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostTagID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostTagId")); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.Property("TagId") + .HasColumnType("int") + .HasColumnName("TagID"); + + b.HasKey("PostTagId"); + + b.HasIndex("PostId"); + + b.HasIndex("TagId"); + + b.ToTable("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.Property("PostId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostId")); + + b.Property("Contents") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PublishedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Tags", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("TagID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("TagId")); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.UserProfile", b => + { + b.Property("UserProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserProfileID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserProfileId")); + + b.Property("About") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProfilePhoto") + .HasColumnType("varbinary(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("UserProfileId"); + + b.HasIndex("UserId"); + + b.ToTable("UserProfile"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Users", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserId")); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("RegisterDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Apikeys", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Apikeys") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_APIKeys_Users"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Comments", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "Commenter") + .WithMany("Comments") + .HasForeignKey("CommenterId") + .IsRequired() + .HasConstraintName("FK_Comments_Users"); + + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_Comments_Posts"); + + b.Navigation("Commenter"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Favourites", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("Favourites") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_Favourites_Posts"); + + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Favourites") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_Favourites_Users"); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Followings", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "Following") + .WithMany("FollowingsFollowing") + .HasForeignKey("FollowingId") + .IsRequired() + .HasConstraintName("FK_FollowingRelationships_Users1"); + + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("FollowingsUser") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_FollowingRelationships_Users"); + + b.Navigation("Following"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostColors", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("PostColors") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_PostColors_Posts"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostTags", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("PostTags") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_PostTags_Posts"); + + b.HasOne("PaletteCoreAPI.Models.Tags", "Tag") + .WithMany("PostTags") + .HasForeignKey("TagId") + .IsRequired() + .HasConstraintName("FK_PostTags_Tags"); + + b.Navigation("Post"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_Posts_Users"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.UserProfile", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("UserProfile") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_UserProfile_Users1"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.Navigation("Comments"); + + b.Navigation("Favourites"); + + b.Navigation("PostColors"); + + b.Navigation("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Tags", b => + { + b.Navigation("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Users", b => + { + b.Navigation("Apikeys"); + + b.Navigation("Comments"); + + b.Navigation("Favourites"); + + b.Navigation("FollowingsFollowing"); + + b.Navigation("FollowingsUser"); + + b.Navigation("Posts"); + + b.Navigation("UserProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.cs b/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.cs new file mode 100644 index 0000000..1101853 --- /dev/null +++ b/PaletteCoreAPI/Migrations/20260724121559_InitialCreate.cs @@ -0,0 +1,323 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PaletteCoreAPI.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + TagID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Tag = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.TagID); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + UserID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Username = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Password = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + RegisterDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.UserID); + }); + + migrationBuilder.CreateTable( + name: "APIKeys", + columns: table => new + { + APIKeyID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserID = table.Column(type: "int", nullable: false), + APIRole = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + UserKey = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "(newsequentialid())"), + KeyCreateDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK__APIKeys__C5971C76B3D13722", x => x.APIKeyID); + table.ForeignKey( + name: "FK_APIKeys_Users", + column: x => x.UserID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "Followings", + columns: table => new + { + FollowingsID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserID = table.Column(type: "int", nullable: false), + FollowingID = table.Column(type: "int", nullable: false), + FollowedDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK_Followings", x => x.FollowingsID); + table.ForeignKey( + name: "FK_FollowingRelationships_Users", + column: x => x.UserID, + principalTable: "Users", + principalColumn: "UserID"); + table.ForeignKey( + name: "FK_FollowingRelationships_Users1", + column: x => x.FollowingID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "Posts", + columns: table => new + { + PostID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserID = table.Column(type: "int", nullable: false), + Title = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Contents = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + PublishedDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK_Posts", x => x.PostID); + table.ForeignKey( + name: "FK_Posts_Users", + column: x => x.UserID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "UserProfile", + columns: table => new + { + UserProfileID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserID = table.Column(type: "int", nullable: false), + FirstName = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + LastName = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Email = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + About = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + ProfilePhoto = table.Column(type: "varbinary(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserProfile", x => x.UserProfileID); + table.ForeignKey( + name: "FK_UserProfile_Users1", + column: x => x.UserID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "Comments", + columns: table => new + { + CommentID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CommenterID = table.Column(type: "int", nullable: false), + PostID = table.Column(type: "int", nullable: false), + CommentText = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + CommentedDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK_Comments", x => x.CommentID); + table.ForeignKey( + name: "FK_Comments_Posts", + column: x => x.PostID, + principalTable: "Posts", + principalColumn: "PostID"); + table.ForeignKey( + name: "FK_Comments_Users", + column: x => x.CommenterID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "Favourites", + columns: table => new + { + FavouriteID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PostID = table.Column(type: "int", nullable: false), + UserID = table.Column(type: "int", nullable: false), + FavouriteDate = table.Column(type: "date", nullable: false, defaultValueSql: "(getdate())") + }, + constraints: table => + { + table.PrimaryKey("PK_Favourites", x => x.FavouriteID); + table.ForeignKey( + name: "FK_Favourites_Posts", + column: x => x.PostID, + principalTable: "Posts", + principalColumn: "PostID"); + table.ForeignKey( + name: "FK_Favourites_Users", + column: x => x.UserID, + principalTable: "Users", + principalColumn: "UserID"); + }); + + migrationBuilder.CreateTable( + name: "PostColors", + columns: table => new + { + PostColorID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PostID = table.Column(type: "int", nullable: false), + ColorHEX1 = table.Column(type: "int", nullable: false), + ColorHEX2 = table.Column(type: "int", nullable: false), + ColorHEX3 = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostColors", x => x.PostColorID); + table.ForeignKey( + name: "FK_PostColors_Posts", + column: x => x.PostID, + principalTable: "Posts", + principalColumn: "PostID"); + }); + + migrationBuilder.CreateTable( + name: "PostTags", + columns: table => new + { + PostTagID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PostID = table.Column(type: "int", nullable: false), + TagID = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostTags", x => x.PostTagID); + table.ForeignKey( + name: "FK_PostTags_Posts", + column: x => x.PostID, + principalTable: "Posts", + principalColumn: "PostID"); + table.ForeignKey( + name: "FK_PostTags_Tags", + column: x => x.TagID, + principalTable: "Tags", + principalColumn: "TagID"); + }); + + migrationBuilder.CreateIndex( + name: "IX_APIKeys_UserID", + table: "APIKeys", + column: "UserID"); + + migrationBuilder.CreateIndex( + name: "IX_Comments_CommenterID", + table: "Comments", + column: "CommenterID"); + + migrationBuilder.CreateIndex( + name: "IX_Comments_PostID", + table: "Comments", + column: "PostID"); + + migrationBuilder.CreateIndex( + name: "IX_Favourites_PostID", + table: "Favourites", + column: "PostID"); + + migrationBuilder.CreateIndex( + name: "IX_Favourites_UserID", + table: "Favourites", + column: "UserID"); + + migrationBuilder.CreateIndex( + name: "IX_Followings_FollowingID", + table: "Followings", + column: "FollowingID"); + + migrationBuilder.CreateIndex( + name: "IX_Followings_UserID", + table: "Followings", + column: "UserID"); + + migrationBuilder.CreateIndex( + name: "IX_PostColors_PostID", + table: "PostColors", + column: "PostID"); + + migrationBuilder.CreateIndex( + name: "IX_Posts_UserID", + table: "Posts", + column: "UserID"); + + migrationBuilder.CreateIndex( + name: "IX_PostTags_PostID", + table: "PostTags", + column: "PostID"); + + migrationBuilder.CreateIndex( + name: "IX_PostTags_TagID", + table: "PostTags", + column: "TagID"); + + migrationBuilder.CreateIndex( + name: "IX_UserProfile_UserID", + table: "UserProfile", + column: "UserID"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "APIKeys"); + + migrationBuilder.DropTable( + name: "Comments"); + + migrationBuilder.DropTable( + name: "Favourites"); + + migrationBuilder.DropTable( + name: "Followings"); + + migrationBuilder.DropTable( + name: "PostColors"); + + migrationBuilder.DropTable( + name: "PostTags"); + + migrationBuilder.DropTable( + name: "UserProfile"); + + migrationBuilder.DropTable( + name: "Posts"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/PaletteCoreAPI/Migrations/PaletteContextModelSnapshot.cs b/PaletteCoreAPI/Migrations/PaletteContextModelSnapshot.cs new file mode 100644 index 0000000..e8e956d --- /dev/null +++ b/PaletteCoreAPI/Migrations/PaletteContextModelSnapshot.cs @@ -0,0 +1,499 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PaletteCoreAPI.Models; + +#nullable disable + +namespace PaletteCoreAPI.Migrations +{ + [DbContext(typeof(PaletteContext))] + partial class PaletteContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PaletteCoreAPI.Models.Apikeys", b => + { + b.Property("ApikeyId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("APIKeyID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ApikeyId")); + + b.Property("Apirole") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("APIRole"); + + b.Property("KeyCreateDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.Property("UserKey") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("(newsequentialid())"); + + b.HasKey("ApikeyId") + .HasName("PK__APIKeys__C5971C76B3D13722"); + + b.HasIndex("UserId"); + + b.ToTable("APIKeys", (string)null); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Comments", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("CommentID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CommentId")); + + b.Property("CommentText") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CommentedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("CommenterId") + .HasColumnType("int") + .HasColumnName("CommenterID"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.HasKey("CommentId"); + + b.HasIndex("CommenterId"); + + b.HasIndex("PostId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Favourites", b => + { + b.Property("FavouriteId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("FavouriteID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("FavouriteId")); + + b.Property("FavouriteDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("FavouriteId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Favourites"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Followings", b => + { + b.Property("FollowingsId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("FollowingsID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("FollowingsId")); + + b.Property("FollowedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("FollowingId") + .HasColumnType("int") + .HasColumnName("FollowingID"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("FollowingsId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Followings"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostColors", b => + { + b.Property("PostColorId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostColorID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostColorId")); + + b.Property("ColorHex1") + .HasColumnType("int") + .HasColumnName("ColorHEX1"); + + b.Property("ColorHex2") + .HasColumnType("int") + .HasColumnName("ColorHEX2"); + + b.Property("ColorHex3") + .HasColumnType("int") + .HasColumnName("ColorHEX3"); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.HasKey("PostColorId"); + + b.HasIndex("PostId"); + + b.ToTable("PostColors"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostTags", b => + { + b.Property("PostTagId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostTagID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostTagId")); + + b.Property("PostId") + .HasColumnType("int") + .HasColumnName("PostID"); + + b.Property("TagId") + .HasColumnType("int") + .HasColumnName("TagID"); + + b.HasKey("PostTagId"); + + b.HasIndex("PostId"); + + b.HasIndex("TagId"); + + b.ToTable("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.Property("PostId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("PostID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("PostId")); + + b.Property("Contents") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PublishedDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Tags", b => + { + b.Property("TagId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("TagID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("TagId")); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("TagId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.UserProfile", b => + { + b.Property("UserProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserProfileID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserProfileId")); + + b.Property("About") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProfilePhoto") + .HasColumnType("varbinary(max)"); + + b.Property("UserId") + .HasColumnType("int") + .HasColumnName("UserID"); + + b.HasKey("UserProfileId"); + + b.HasIndex("UserId"); + + b.ToTable("UserProfile"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Users", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserId")); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("RegisterDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasDefaultValueSql("(getdate())"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Apikeys", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Apikeys") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_APIKeys_Users"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Comments", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "Commenter") + .WithMany("Comments") + .HasForeignKey("CommenterId") + .IsRequired() + .HasConstraintName("FK_Comments_Users"); + + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_Comments_Posts"); + + b.Navigation("Commenter"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Favourites", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("Favourites") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_Favourites_Posts"); + + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Favourites") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_Favourites_Users"); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Followings", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "Following") + .WithMany("FollowingsFollowing") + .HasForeignKey("FollowingId") + .IsRequired() + .HasConstraintName("FK_FollowingRelationships_Users1"); + + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("FollowingsUser") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_FollowingRelationships_Users"); + + b.Navigation("Following"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostColors", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("PostColors") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_PostColors_Posts"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.PostTags", b => + { + b.HasOne("PaletteCoreAPI.Models.Posts", "Post") + .WithMany("PostTags") + .HasForeignKey("PostId") + .IsRequired() + .HasConstraintName("FK_PostTags_Posts"); + + b.HasOne("PaletteCoreAPI.Models.Tags", "Tag") + .WithMany("PostTags") + .HasForeignKey("TagId") + .IsRequired() + .HasConstraintName("FK_PostTags_Tags"); + + b.Navigation("Post"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_Posts_Users"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.UserProfile", b => + { + b.HasOne("PaletteCoreAPI.Models.Users", "User") + .WithMany("UserProfile") + .HasForeignKey("UserId") + .IsRequired() + .HasConstraintName("FK_UserProfile_Users1"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Posts", b => + { + b.Navigation("Comments"); + + b.Navigation("Favourites"); + + b.Navigation("PostColors"); + + b.Navigation("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Tags", b => + { + b.Navigation("PostTags"); + }); + + modelBuilder.Entity("PaletteCoreAPI.Models.Users", b => + { + b.Navigation("Apikeys"); + + b.Navigation("Comments"); + + b.Navigation("Favourites"); + + b.Navigation("FollowingsFollowing"); + + b.Navigation("FollowingsUser"); + + b.Navigation("Posts"); + + b.Navigation("UserProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PaletteCoreAPI/Models/Apikeys.cs b/PaletteCoreAPI/Models/Apikeys.cs index cd304e9..6cc3f7a 100644 --- a/PaletteCoreAPI/Models/Apikeys.cs +++ b/PaletteCoreAPI/Models/Apikeys.cs @@ -7,10 +7,10 @@ public partial class Apikeys { public int ApikeyId { get; set; } public int UserId { get; set; } - public string Apirole { get; set; } + public string Apirole { get; set; } = null!; public Guid UserKey { get; set; } public DateTime KeyCreateDate { get; set; } - public virtual Users User { get; set; } + public virtual Users User { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/Comments.cs b/PaletteCoreAPI/Models/Comments.cs index 910a5e6..7e35043 100644 --- a/PaletteCoreAPI/Models/Comments.cs +++ b/PaletteCoreAPI/Models/Comments.cs @@ -8,10 +8,10 @@ public partial class Comments public int CommentId { get; set; } public int CommenterId { get; set; } public int PostId { get; set; } - public string CommentText { get; set; } + public string CommentText { get; set; } = null!; public DateTime CommentedDate { get; set; } - public virtual Users Commenter { get; set; } - public virtual Posts Post { get; set; } + public virtual Users Commenter { get; set; } = null!; + public virtual Posts Post { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/Favourites.cs b/PaletteCoreAPI/Models/Favourites.cs index d086c7f..08f8f39 100644 --- a/PaletteCoreAPI/Models/Favourites.cs +++ b/PaletteCoreAPI/Models/Favourites.cs @@ -10,7 +10,7 @@ public partial class Favourites public int UserId { get; set; } public DateTime FavouriteDate { get; set; } - public virtual Posts Post { get; set; } - public virtual Users User { get; set; } + public virtual Posts Post { get; set; } = null!; + public virtual Users User { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/Followings.cs b/PaletteCoreAPI/Models/Followings.cs index 3f5e54a..bd2e43e 100644 --- a/PaletteCoreAPI/Models/Followings.cs +++ b/PaletteCoreAPI/Models/Followings.cs @@ -10,7 +10,7 @@ public partial class Followings public int FollowingId { get; set; } public DateTime FollowedDate { get; set; } - public virtual Users Following { get; set; } - public virtual Users User { get; set; } + public virtual Users Following { get; set; } = null!; + public virtual Users User { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/PaletteContext.cs b/PaletteCoreAPI/Models/PaletteContext.cs index a293bb1..11ef812 100644 --- a/PaletteCoreAPI/Models/PaletteContext.cs +++ b/PaletteCoreAPI/Models/PaletteContext.cs @@ -1,39 +1,24 @@ -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore; namespace PaletteCoreAPI.Models { public partial class PaletteContext : DbContext { - public PaletteContext() - { - } - public PaletteContext(DbContextOptions options) : base(options) { } - public virtual DbSet Apikeys { get; set; } - public virtual DbSet Comments { get; set; } - public virtual DbSet Favourites { get; set; } - public virtual DbSet Followings { get; set; } - public virtual DbSet PostColors { get; set; } - public virtual DbSet PostTags { get; set; } - public virtual DbSet Posts { get; set; } - public virtual DbSet Tags { get; set; } - public virtual DbSet UserProfile { get; set; } - public virtual DbSet Users { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) - { -#warning To protect potentially sensitive information in your connection string, you should move it out of source code. See http://go.microsoft.com/fwlink/?LinkId=723263 for guidance on storing connection strings. - optionsBuilder.UseSqlServer("Server=.\\sqlexpress;Initial Catalog=Palette;Integrated Security=True;"); - } - } + public virtual DbSet Apikeys => Set(); + public virtual DbSet Comments => Set(); + public virtual DbSet Favourites => Set(); + public virtual DbSet Followings => Set(); + public virtual DbSet PostColors => Set(); + public virtual DbSet PostTags => Set(); + public virtual DbSet Posts => Set(); + public virtual DbSet Tags => Set(); + public virtual DbSet UserProfile => Set(); + public virtual DbSet Users => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -268,7 +253,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Password) .IsRequired() - .HasMaxLength(50); + .HasMaxLength(255); entity.Property(e => e.RegisterDate) .HasColumnType("date") @@ -276,7 +261,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Username) .IsRequired() - .HasMaxLength(10); + .HasMaxLength(50); }); OnModelCreatingPartial(modelBuilder); diff --git a/PaletteCoreAPI/Models/PaletteContextFactory.cs b/PaletteCoreAPI/Models/PaletteContextFactory.cs new file mode 100644 index 0000000..ffd60ce --- /dev/null +++ b/PaletteCoreAPI/Models/PaletteContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace PaletteCoreAPI.Models; + +public sealed class PaletteContextFactory : IDesignTimeDbContextFactory +{ + public PaletteContext CreateDbContext(string[] args) + { + var connectionString = + Environment.GetEnvironmentVariable("ConnectionStrings__Database"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "Set ConnectionStrings__Database before running EF Core tools."); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + return new PaletteContext(options); + } +} diff --git a/PaletteCoreAPI/Models/PostColors.cs b/PaletteCoreAPI/Models/PostColors.cs index 6fa5414..3699a97 100644 --- a/PaletteCoreAPI/Models/PostColors.cs +++ b/PaletteCoreAPI/Models/PostColors.cs @@ -11,6 +11,6 @@ public partial class PostColors public int ColorHex2 { get; set; } public int ColorHex3 { get; set; } - public virtual Posts Post { get; set; } + public virtual Posts Post { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/PostTags.cs b/PaletteCoreAPI/Models/PostTags.cs index d183de6..00171a0 100644 --- a/PaletteCoreAPI/Models/PostTags.cs +++ b/PaletteCoreAPI/Models/PostTags.cs @@ -9,7 +9,7 @@ public partial class PostTags public int PostId { get; set; } public int TagId { get; set; } - public virtual Posts Post { get; set; } - public virtual Tags Tag { get; set; } + public virtual Posts Post { get; set; } = null!; + public virtual Tags Tag { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/Posts.cs b/PaletteCoreAPI/Models/Posts.cs index 2a2d631..18914e5 100644 --- a/PaletteCoreAPI/Models/Posts.cs +++ b/PaletteCoreAPI/Models/Posts.cs @@ -15,11 +15,11 @@ public Posts() public int PostId { get; set; } public int UserId { get; set; } - public string Title { get; set; } - public string Contents { get; set; } + public string Title { get; set; } = null!; + public string Contents { get; set; } = null!; public DateTime PublishedDate { get; set; } - public virtual Users User { get; set; } + public virtual Users User { get; set; } = null!; public virtual ICollection Comments { get; set; } public virtual ICollection Favourites { get; set; } public virtual ICollection PostColors { get; set; } diff --git a/PaletteCoreAPI/Models/Tags.cs b/PaletteCoreAPI/Models/Tags.cs index 3cea6d9..8e4c02e 100644 --- a/PaletteCoreAPI/Models/Tags.cs +++ b/PaletteCoreAPI/Models/Tags.cs @@ -11,7 +11,7 @@ public Tags() } public int TagId { get; set; } - public string Tag { get; set; } + public string Tag { get; set; } = null!; public virtual ICollection PostTags { get; set; } } diff --git a/PaletteCoreAPI/Models/UserProfile.cs b/PaletteCoreAPI/Models/UserProfile.cs index 6c8f5d9..2634e3d 100644 --- a/PaletteCoreAPI/Models/UserProfile.cs +++ b/PaletteCoreAPI/Models/UserProfile.cs @@ -7,12 +7,12 @@ public partial class UserProfile { public int UserProfileId { get; set; } public int UserId { get; set; } - public string FirstName { get; set; } - public string LastName { get; set; } - public string Email { get; set; } - public string About { get; set; } - public byte[] ProfilePhoto { get; set; } + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public string Email { get; set; } = null!; + public string About { get; set; } = null!; + public byte[]? ProfilePhoto { get; set; } - public virtual Users User { get; set; } + public virtual Users User { get; set; } = null!; } } diff --git a/PaletteCoreAPI/Models/Users.cs b/PaletteCoreAPI/Models/Users.cs index b888879..06682f1 100644 --- a/PaletteCoreAPI/Models/Users.cs +++ b/PaletteCoreAPI/Models/Users.cs @@ -17,8 +17,8 @@ public Users() } public int UserId { get; set; } - public string Username { get; set; } - public string Password { get; set; } + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; public DateTime RegisterDate { get; set; } public virtual ICollection Apikeys { get; set; } diff --git a/PaletteCoreAPI/PaletteCoreAPI.csproj b/PaletteCoreAPI/PaletteCoreAPI.csproj index f009a2e..e6cfa3d 100644 --- a/PaletteCoreAPI/PaletteCoreAPI.csproj +++ b/PaletteCoreAPI/PaletteCoreAPI.csproj @@ -1,26 +1,22 @@ - netcoreapp3.1 - - - - C:\Users\Serkan\source\repos\PaletteCoreAPI\PaletteCoreAPI\PaletteCoreAPI.xml + net10.0 + enable + enable + PaletteCoreAPI-development + true + $(NoWarn);1591 - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/PaletteCoreAPI/Program.cs b/PaletteCoreAPI/Program.cs index d738692..3ac83b5 100644 --- a/PaletteCoreAPI/Program.cs +++ b/PaletteCoreAPI/Program.cs @@ -1,26 +1,83 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace PaletteCoreAPI +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi; +using PaletteCoreAPI.Models; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddControllers(); +builder.Services.AddHealthChecks(); +builder.Services.AddScoped, PasswordHasher>(); +builder.Services.AddDbContext((services, options) => { - public class Program + var connectionString = services + .GetRequiredService() + .GetConnectionString("Database"); + + if (string.IsNullOrWhiteSpace(connectionString)) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + throw new InvalidOperationException( + "ConnectionStrings:Database is required. Configure it with user-secrets " + + "or the ConnectionStrings__Database environment variable."); } -} + + options.UseSqlServer(connectionString); +}); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = builder.Configuration["Authentication:Authority"]; + options.Audience = builder.Configuration["Authentication:Audience"]; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + }); + +builder.Services.AddAuthorizationBuilder() + .SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()) + .AddPolicy("admin", policy => + policy.RequireAssertion(context => + context.User.IsInRole("admin") || + context.User.HasClaim("scope", "palette.admin"))); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Palette Core API", + Version = "v1", + Description = "Authenticated REST API for palette-oriented social content." + }); + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "OIDC/OAuth 2.0 access token" + }); + options.AddSecurityRequirement(document => new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", document)] = [] + }); +}); + +var app = builder.Build(); + +app.UseExceptionHandler(); +app.UseHttpsRedirection(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapHealthChecks("/health").AllowAnonymous(); +app.MapControllers(); + +app.Run(); + +public partial class Program; diff --git a/PaletteCoreAPI/Properties/launchSettings.json b/PaletteCoreAPI/Properties/launchSettings.json index 8e6162c..c007a2d 100644 --- a/PaletteCoreAPI/Properties/launchSettings.json +++ b/PaletteCoreAPI/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,8 +20,8 @@ "PaletteCoreAPI": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "weatherforecast", - "applicationUrl": "http://localhost:5000", + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7042;http://localhost:5042", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/PaletteCoreAPI/Startup.cs b/PaletteCoreAPI/Startup.cs deleted file mode 100644 index 0fd32c0..0000000 --- a/PaletteCoreAPI/Startup.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using PaletteCoreAPI.Models; - -namespace PaletteCoreAPI -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - services.AddDbContext(op => op.UseSqlServer(Configuration.GetConnectionString("Database"))); - services.AddSwaggerDocument(config => - { - config.PostProcess = (doc => - { - doc.Info.Version = "v2"; - doc.Info.Title = "Palette Core API"; - doc.Info.Contact = new NSwag.OpenApiContact() - { - Name = "Serkan", - Url = "https://www.codesitory.com/", - Email = "info@codesitory.com", - }; - }); - }); - } - - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); - app.UseOpenApi(); - app.UseSwaggerUi3(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/PaletteCoreAPI/WeatherForecast.cs b/PaletteCoreAPI/WeatherForecast.cs deleted file mode 100644 index feeb8f9..0000000 --- a/PaletteCoreAPI/WeatherForecast.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace PaletteCoreAPI -{ - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string Summary { get; set; } - } -} diff --git a/PaletteCoreAPI/appsettings.json b/PaletteCoreAPI/appsettings.json index 4c7297c..7b29ab5 100644 --- a/PaletteCoreAPI/appsettings.json +++ b/PaletteCoreAPI/appsettings.json @@ -1,13 +1,16 @@ { + "Authentication": { + "Audience": "palette-api", + "Authority": "" + }, "Logging": { "LogLevel": { "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { - "Database": "Server=.\\sqlexpress;Initial Catalog=Palette;Integrated Security=True" + "Database": "" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 8c58ff8..1fc740a 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,72 @@ # PaletteCoreAPI -PaletteCoreAPI is an ASP.NET Core Web API prototype for a palette-oriented social application. It exposes CRUD endpoints for users, profiles, posts, colours, tags, comments, favourites, following relationships, and API-key records backed by SQL Server. +PaletteCoreAPI is an authenticated ASP.NET Core REST API for a palette-oriented social platform. It models users, profiles, posts, colours, tags, comments, favourites, following relationships, and API-key records in SQL Server. -> **Project status:** This repository targets .NET Core 3.1, which is out of support. It is preserved as a backend engineering and learning project and should not be deployed without security, validation, and dependency updates. +## Engineering highlights -## Overview +- .NET 10 and ASP.NET Core controllers +- Entity Framework Core with SQL Server and a committed initial migration +- JWT bearer authentication with a secure fallback policy +- role- or scope-based protection for user and API-key administration +- validated request and response DTOs that prevent entity over-posting +- password hashing with ASP.NET Core Identity's `PasswordHasher` +- API keys returned once on creation and omitted from later reads +- OpenAPI/Swagger documentation, Problem Details, and a public health endpoint +- integration tests with an in-memory database +- GitHub Actions build and test workflow -The project is a direct controller-to-database API. ASP.NET Core routes requests to resource controllers, each controller uses an injected `PaletteContext`, and Entity Framework Core maps the request to the existing Palette SQL Server schema. - -The implementation demonstrates: - -- attribute-routed REST-style controllers -- asynchronous Entity Framework Core CRUD operations -- dependency-injected database context configuration -- database-first entity and relationship mapping -- generated OpenAPI documentation through NSwag - -## Features - -- CRUD operations for ten Palette domain resources -- SQL Server persistence through Entity Framework Core 3.1 -- asynchronous list, detail, create, update, and delete actions -- OpenAPI document and Swagger UI middleware -- mapped relationships for posts, tags, comments, favourites, profiles, and follows -- the default ASP.NET Core `WeatherForecast` sample endpoint, retained from the project template - -## Technology Stack +## Technology stack | Area | Technology | |---|---| | Language | C# | -| Runtime | .NET Core 3.1 | +| Runtime | .NET 10 | | Web framework | ASP.NET Core Web API | -| Data access | Entity Framework Core 3.1.9 | +| Data access | Entity Framework Core 10 | | Database | Microsoft SQL Server | -| API documentation | NSwag.AspNetCore 13.8.2 | -| Architecture | Resource controllers with an injected EF Core `DbContext` | -| Development solution | Visual Studio 2019 solution | - -The project also references the EF Core SQLite provider, but the configured `PaletteContext` uses SQL Server. +| Authentication | JWT bearer / OpenID Connect authority | +| API documentation | Swashbuckle OpenAPI and Swagger UI | +| Testing | xUnit, `WebApplicationFactory`, EF Core InMemory | +| Automation | GitHub Actions | ## Architecture -`Startup.ConfigureServices` registers MVC controllers and `PaletteContext`. The context receives the `Database` connection string and maps the existing relational schema in `OnModelCreating`. There is no separate service or repository layer in this implementation. +The API uses a focused controller-to-database design. Controllers accept validated contracts, map them to the existing domain entities, and use `PaletteContext` for persistence. Authentication is applied globally; only `/health` is anonymous. ```mermaid flowchart LR - Client[HTTP client] --> Routing[ASP.NET Core routing] - Routing --> Controllers[Resource controllers] - Controllers --> Context[PaletteContext] - Context --> EF[Entity Framework Core] - EF --> SQL[(SQL Server Palette database)] - Startup[Startup] -. registers .-> Controllers - Startup -. configures .-> Context - NSwag[NSwag middleware] -. describes .-> Controllers + Client["HTTP client"] --> Auth["JWT bearer authentication"] + Auth --> Controllers["API controllers"] + Controllers --> DTOs["Validated request/response DTOs"] + Controllers --> Context["PaletteContext"] + Context --> EF["Entity Framework Core"] + EF --> SQL[("SQL Server")] + Swagger["Swagger UI"] -. documents .-> Controllers + Health["/health"] --> Checks["Health checks"] ``` -### Request lifecycle - -The following sequence uses the implemented `PostsController` detail action as a representative read request: +### Request flow ```mermaid sequenceDiagram participant Client - participant PostsController - participant PaletteContext - participant SQLServer - - Client->>PostsController: GET /api/Posts/{id} - PostsController->>PaletteContext: Posts.FindAsync(id) - PaletteContext->>SQLServer: Query post by primary key - SQLServer-->>PaletteContext: Post row or no result - alt post exists - PaletteContext-->>PostsController: Posts entity - PostsController-->>Client: 200 OK with JSON - else post does not exist - PaletteContext-->>PostsController: null - PostsController-->>Client: 404 Not Found + participant Auth as JWT middleware + participant Posts as PostsController + participant DB as PaletteContext + + Client->>Auth: GET /api/Posts/{id} + bearer token + Auth->>Posts: Authenticated request + Posts->>DB: Read post without tracking + alt Post exists + DB-->>Posts: Posts entity + Posts-->>Client: 200 OK + PostDto + else Post is missing + DB-->>Posts: null + Posts-->>Client: 404 Not Found end ``` -## API Endpoints - -All domain controllers use the `api/[controller]` route and expose the same five CRUD operations. Replace `{resource}` with one of the verified base routes in the next table. - -| Method | Route | Purpose | -|---|---|---| -| `GET` | `/api/{resource}` | Return all records for the resource | -| `GET` | `/api/{resource}/{id}` | Return one record by primary key | -| `POST` | `/api/{resource}` | Create a record from the JSON entity payload | -| `PUT` | `/api/{resource}/{id}` | Replace the mapped record when the route ID matches the payload ID | -| `DELETE` | `/api/{resource}/{id}` | Delete and return the matching record | - -Verified resource routes: - -| Base route | Controller | Entity | -|---|---|---| -| `/api/Apikeys` | `ApikeysController` | `Apikeys` | -| `/api/Comments` | `CommentsController` | `Comments` | -| `/api/Favourites` | `FavouritesController` | `Favourites` | -| `/api/Followings` | `FollowingsController` | `Followings` | -| `/api/PostColors` | `PostColorsController` | `PostColors` | -| `/api/PostTags` | `PostTagsController` | `PostTags` | -| `/api/Posts` | `PostsController` | `Posts` | -| `/api/Tags` | `TagsController` | `Tags` | -| `/api/UserProfiles` | `UserProfilesController` | `UserProfile` | -| `/api/Users` | `UsersController` | `Users` | - -The template endpoint is separate from the Palette domain: - -| Method | Route | Purpose | -|---|---|---| -| `GET` | `/WeatherForecast` | Return five generated sample forecasts | - -The controllers bind EF Core entities directly; the repository does not define separate request or response DTOs. - -| Model | Main fields | -|---|---| -| `Users` | `UserId`, `Username`, `Password`, `RegisterDate` | -| `UserProfile` | `UserProfileId`, `UserId`, `FirstName`, `LastName`, `Email`, `About`, `ProfilePhoto` | -| `Posts` | `PostId`, `UserId`, `Title`, `Contents`, `PublishedDate` | -| `PostColors` | `PostColorId`, `PostId`, `ColorHex1`, `ColorHex2`, `ColorHex3` | -| `Tags` | `TagId`, `Tag` | -| `PostTags` | `PostTagId`, `PostId`, `TagId` | -| `Comments` | `CommentId`, `CommenterId`, `PostId`, `CommentText`, `CommentedDate` | -| `Favourites` | `FavouriteId`, `PostId`, `UserId`, `FavouriteDate` | -| `Followings` | `FollowingsId`, `UserId`, `FollowingId`, `FollowedDate` | -| `Apikeys` | `ApikeyId`, `UserId`, `Apirole`, `UserKey`, `KeyCreateDate` | - -### Example request - -With the application running on its default project URL: - -```bash -curl --request GET http://localhost:5000/api/Posts -``` - -No authentication is enforced by the current application. - -## Database Model - -`PaletteContext` contains ten application entity sets. The relationships below are defined in `OnModelCreating`; no EF Core migrations or database creation scripts are committed. +## Domain model ```mermaid erDiagram @@ -152,147 +78,108 @@ erDiagram USERS ||--o{ FAVOURITES : creates POSTS ||--o{ FAVOURITES : receives USERS ||--o{ FOLLOWINGS : follows - USERS ||--o{ FOLLOWINGS : is_followed POSTS ||--o{ POST_COLORS : contains POSTS ||--o{ POST_TAGS : categorised_by TAGS ||--o{ POST_TAGS : assigned_through - - USERS { - int UserId PK - string Username - string Password - date RegisterDate - } - APIKEYS { - int ApikeyId PK - int UserId FK - guid UserKey - string Apirole - date KeyCreateDate - } - POSTS { - int PostId PK - int UserId FK - string Title - string Contents - date PublishedDate - } - USER_PROFILE { - int UserProfileId PK - int UserId FK - string Email - binary ProfilePhoto - } - COMMENTS { - int CommentId PK - int CommenterId FK - int PostId FK - string CommentText - date CommentedDate - } - FAVOURITES { - int FavouriteId PK - int PostId FK - int UserId FK - date FavouriteDate - } - FOLLOWINGS { - int FollowingsId PK - int UserId FK - int FollowingId FK - date FollowedDate - } - POST_COLORS { - int PostColorId PK - int PostId FK - int ColorHex1 - int ColorHex2 - int ColorHex3 - } - POST_TAGS { - int PostTagId PK - int PostId FK - int TagId FK - } - TAGS { - int TagId PK - string Tag - } ``` -## Project Structure +## API surface + +All domain routes require an authenticated user. + +| Resource | Base route | Operations | +|---|---|---| +| Posts | `/api/Posts` | GET, GET by ID, POST, PUT, DELETE | +| Post colours | `/api/PostColors` | GET, GET by ID, POST, PUT, DELETE | +| Tags | `/api/Tags` | GET, GET by ID, POST, PUT, DELETE | +| Post tags | `/api/PostTags` | GET, GET by ID, POST, PUT, DELETE | +| Comments | `/api/Comments` | GET, GET by ID, POST, PUT, DELETE | +| Favourites | `/api/Favourites` | GET, GET by ID, POST, PUT, DELETE | +| Followings | `/api/Followings` | GET, GET by ID, POST, PUT, DELETE | +| User profiles | `/api/UserProfiles` | GET, GET by ID, POST, PUT, DELETE | +| Users | `/api/Users` | Admin policy; GET, POST, PUT, DELETE | +| API keys | `/api/Apikeys` | Admin policy; GET, POST, PUT, DELETE | +| Health | `/health` | Anonymous GET | + +The admin policy accepts either the `admin` role or a `scope` claim containing `palette.admin`. User responses never include password hashes. API-key list and detail responses never include the stored key; the generated value is returned only by the create operation. + +## Project structure ```text PaletteCoreAPI/ +├── .github/workflows/ci.yml Build and test automation +├── PaletteCoreAPI/ +│ ├── Contracts/ Validated public API contracts +│ ├── Controllers/ Authenticated HTTP endpoints +│ ├── Migrations/ EF Core database migration +│ ├── Models/ Entities and PaletteContext +│ └── Program.cs Services and HTTP pipeline +├── PaletteCoreAPI.Tests/ Integration and contract tests ├── PaletteCoreAPI.sln -└── PaletteCoreAPI/ - ├── Controllers/ HTTP CRUD controllers - ├── Models/ EF Core entities and PaletteContext - ├── Properties/ Local launch profiles - ├── Program.cs Generic host entry point - ├── Startup.cs Services and HTTP pipeline configuration - ├── appsettings.json Logging and database configuration - └── PaletteCoreAPI.csproj +└── global.json ``` -## Getting Started +## Getting started ### Prerequisites -- the .NET Core 3.1 SDK and runtime, or a compatible SDK that can build the `netcoreapp3.1` target -- SQL Server or SQL Server Express -- a Palette database matching the model in `PaletteContext` -- Visual Studio 2019 or another editor with C# support +- .NET SDK 10.0.302 or a compatible .NET 10 SDK +- SQL Server +- an OpenID Connect or OAuth 2.0 authority that issues JWT access tokens -The repository does not include migrations, a schema script, or seed data. A compatible database must therefore already exist before database-backed endpoints can run successfully. +### Configure local settings -### Configure the database +The repository contains no database password, identity-provider secret, or production endpoint. Use .NET user secrets from the API project: -The application currently reads `ConnectionStrings:Database` from `PaletteCoreAPI/appsettings.json`. A local SQL Server Express connection is committed as a development default, and `PaletteContext.OnConfiguring` contains the same style of local fallback. +```bash +dotnet user-secrets --project PaletteCoreAPI/PaletteCoreAPI.csproj set \ + "ConnectionStrings:Database" \ + "Server=localhost;Database=Palette;Integrated Security=True;TrustServerCertificate=True" -Override the connection string without editing the tracked file by setting an environment variable: +dotnet user-secrets --project PaletteCoreAPI/PaletteCoreAPI.csproj set \ + "Authentication:Authority" \ + "https://identity.example.com" -```bash -export ConnectionStrings__Database='Server=localhost;Database=Palette;Integrated Security=True' +dotnet user-secrets --project PaletteCoreAPI/PaletteCoreAPI.csproj set \ + "Authentication:Audience" \ + "palette-api" ``` -Use a connection string appropriate for the local SQL Server installation. Do not commit database credentials. +Use a connection string and authority appropriate for your environment. Production configuration should come from the deployment platform's secret store or environment variables. -### Restore, build, and run +### Create the database + +```bash +dotnet tool restore +export ConnectionStrings__Database='Server=localhost;Database=Palette;Integrated Security=True;TrustServerCertificate=True' +dotnet tool run dotnet-ef database update --project PaletteCoreAPI/PaletteCoreAPI.csproj +``` + +### Build, test, and run ```bash dotnet restore PaletteCoreAPI.sln -dotnet build PaletteCoreAPI.sln +dotnet build PaletteCoreAPI.sln --configuration Release --no-restore +dotnet test PaletteCoreAPI.sln --configuration Release --no-build dotnet run --project PaletteCoreAPI/PaletteCoreAPI.csproj ``` -The project launch profile uses `http://localhost:5000`. NSwag exposes the Swagger UI at `/swagger` while the application is running. - -## Build Verification +The development launch profile opens Swagger UI at `https://localhost:7042/swagger`. The public health probe is available at `https://localhost:7042/health`. -The solution was restored and compiled successfully during the documentation audit with .NET SDK 7.0.304. The build produced one intentional source warning from `PaletteContext` about moving the fallback connection string out of source control. Runtime database access was not tested because the repository does not include the required SQL Server database. +## Verification -## Security and Operational Limitations +The modernized solution builds with zero warnings and zero errors. The automated suite verifies that: -This code is a prototype and is not production-ready: +- the health endpoint remains public; +- representative domain routes reject anonymous access; +- public contracts do not expose password or stored API-key fields. -- no authentication scheme or authorization policy is registered -- every domain controller, including `ApikeysController`, is publicly routable -- `Users.Password` is represented as a normal string and is exposed through direct entity binding -- request DTOs, response DTOs, validation boundaries, and over-posting protection are absent -- the local database connection is hard-coded in both configuration and the context fallback -- exception handling is limited to scaffolded EF Core update-concurrency handling -- there are no automated tests, migrations, health checks, container files, or CI configuration -- .NET Core 3.1 and the referenced package versions are out of support +## Security notes -## Potential Modernization +This repository no longer contains a fallback database connection string. Authentication configuration is external, entity types are not accepted as public request bodies, passwords are hashed before persistence, and administrative resources require an explicit policy. -Possible modernization steps include upgrading to a supported ASP.NET Core release, replacing direct entity exposure with request and response DTOs, adding validated authentication and role-based authorization, hashing passwords through a proven identity system, moving all connection settings to secure configuration, adding EF Core migrations and integration tests, and documenting a repeatable deployment process. +The API delegates token issuance, account recovery, key rotation, rate limiting, and operational monitoring to surrounding infrastructure. Those production concerns must be configured by the deploying environment. ## Author Serkan Seker - -## License - -No open-source license has currently been specified. diff --git a/global.json b/global.json new file mode 100644 index 0000000..5f5c8e8 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestPatch" + } +} From 4b26d5ccfb7dc72a40438102217f15e4a8ea862f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serkan=20=C5=9Eeker?= Date: Fri, 24 Jul 2026 15:22:03 +0300 Subject: [PATCH 2/2] ci: add .NET build and test workflow --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3a6aa2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore PaletteCoreAPI.sln + + - name: Build + run: dotnet build PaletteCoreAPI.sln --configuration Release --no-restore + + - name: Test + run: dotnet test PaletteCoreAPI.sln --configuration Release --no-build