diff --git a/API/Controllers/AuthController.cs b/API/Controllers/AuthController.cs index ecc7962..34dc688 100644 --- a/API/Controllers/AuthController.cs +++ b/API/Controllers/AuthController.cs @@ -7,78 +7,83 @@ namespace API.Controllers { - [Route("api/[controller]")] - [ApiController] - public class AuthController : ControllerBase + // FIXME: the name of the endpoint address must be lowercase. + // TODO: have a look at this standard guidelines: https://opensource.zalando.com/restful-api-guidelines/#table-of-contents + [Route("api/[controller]")] + [ApiController] + public class AuthController : ControllerBase + { + private readonly UserManager userManager; + private readonly ITokenRepository tokenRepository; + + public AuthController(UserManager userManager, ITokenRepository tokenRepository) { - private readonly UserManager userManager; - private readonly ITokenRepository tokenRepository; + this.userManager = userManager; + this.tokenRepository = tokenRepository; + } - public AuthController(UserManager userManager, ITokenRepository tokenRepository) - { - this.userManager = userManager; - this.tokenRepository = tokenRepository; - } + [HttpPost] + [Route("Register")] + public async Task Register([FromBody] RegisterDto registerDto) + { + // FIXME: some validation logic? together with mapping logic. + var applicationUser = new ApplicationUser() + { + UserName = registerDto.Username, + Email = registerDto.Username, + Firstname = registerDto.Firstname, + Lastname = registerDto.Lastname + }; + var identityResult = await userManager.CreateAsync(applicationUser, registerDto.Password); + if (!identityResult.Succeeded) + { + + var errors = identityResult.Errors.Select(e => e.Description); + return BadRequest(new { message = "User creation failed", errors }); + } - [HttpPost] - [Route("Register")] - public async Task Register([FromBody] RegisterDto registerDto) + if (registerDto.Roles != null && registerDto.Roles.Any()) + { + identityResult = await userManager.AddToRolesAsync(applicationUser, registerDto.Roles); + if (!identityResult.Succeeded) { - var applicationUser = new ApplicationUser() - { - UserName = registerDto.Username, - Email = registerDto.Username, - Firstname = registerDto.Firstname, - Lastname = registerDto.Lastname - }; - var identityResult = await userManager.CreateAsync(applicationUser, registerDto.Password); - if (!identityResult.Succeeded) - { - - var errors = identityResult.Errors.Select(e => e.Description); - return BadRequest(new { message = "User creation failed", errors }); - } + var errors = identityResult.Errors.Select(e => e.Description); + return BadRequest(new { message = "Adding roles failed", errors }); + } + } - if (registerDto.Roles != null && registerDto.Roles.Any()) - { - identityResult = await userManager.AddToRolesAsync(applicationUser, registerDto.Roles); - if (!identityResult.Succeeded) - { - var errors = identityResult.Errors.Select(e => e.Description); - return BadRequest(new { message = "Adding roles failed", errors }); - } - } + // FIXME: this is a POST, it creates a record based on an entity. It must return 201 - Created together with the URI of the resource. + return Ok("User registered successfully"); + } - return Ok("User registered successfully"); - } + [HttpPost] + [Route("Login")] + public async Task Login([FromBody] LoginDto loginDto) + { + var applicationUser = await userManager.FindByEmailAsync(loginDto.Username); + if (applicationUser != null && await userManager.CheckPasswordAsync(applicationUser, loginDto.Password)) + { - [HttpPost] - [Route("Login")] - public async Task Login([FromBody] LoginDto loginDto) + var roles = await userManager.GetRolesAsync(applicationUser); + // [Q]: we are not logging users without any roles? If this is the case, we're returning "Invalid username or password" which is misleading. + if (roles != null && roles.Count > 0) { - var applicationUser = await userManager.FindByEmailAsync(loginDto.Username); - if (applicationUser != null && await userManager.CheckPasswordAsync(applicationUser, loginDto.Password)) - { - - var roles = await userManager.GetRolesAsync(applicationUser); - if (roles != null && roles.Count > 0) - { - var jwtToken = tokenRepository.CreateJWTToken(applicationUser, roles.ToList()); - var response = new LoginResponseDto() - { - JwtToken = jwtToken - }; - return Ok(response); - } + var jwtToken = tokenRepository.CreateJWTToken(applicationUser, roles.ToList()); + var response = new LoginResponseDto() + { + JwtToken = jwtToken + }; + return Ok(response); + } - } - return BadRequest("Invalid username or password"); - } + } + return BadRequest("Invalid username or password"); } + } } diff --git a/API/Controllers/BlogPostsController.cs b/API/Controllers/BlogPostsController.cs index e255056..54711e4 100644 --- a/API/Controllers/BlogPostsController.cs +++ b/API/Controllers/BlogPostsController.cs @@ -11,232 +11,241 @@ namespace API.Controllers { - [Route("api/[controller]")] - [ApiController] - public class BlogPostsController : ControllerBase + [Route("api/[controller]")] + [ApiController] + public class BlogPostsController : ControllerBase + { + private readonly IBlogPostRepository repository; + private readonly IMapper mapper; + private readonly IMemoryCache memoryCache; + + public BlogPostsController(IBlogPostRepository repository, IMapper mapper, IMemoryCache memoryCache) { - private readonly IBlogPostRepository repository; - private readonly IMapper mapper; - private readonly IMemoryCache memoryCache; + this.repository = repository; + this.mapper = mapper; + this.memoryCache = memoryCache; + } - public BlogPostsController(IBlogPostRepository repository, IMapper mapper, IMemoryCache memoryCache) - { - this.repository = repository; - this.mapper = mapper; - this.memoryCache = memoryCache; - } + /// + /// Gets all blog posts with filtering, sorting, and pagination. + /// + /// Field to filter on (e.g., "Title", "Content"). + /// Query string to filter for. + /// Field to sort by (e.g., "Title", "DateCreated"). + /// Sort direction (true = ascending, false = descending). Default is true. + /// Page number. + /// Number of records per page. + /// A list of filtered and paginated blog posts. + [HttpGet] + //[Authorize(Roles = "Reader,Writer,Admin")] + public async Task GetBlogPosts( + [FromQuery] string? filterOn, + [FromQuery] string? filterQuery, + [FromQuery] string? sortBy, + [FromQuery] bool? isAscending, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20) + { + var blogPosts = await repository.GetAllAsync( + filterOn, + filterQuery, + sortBy, + isAscending ?? true, + pageNumber, + pageSize); - /// - /// Gets all blog posts with filtering, sorting, and pagination. - /// - /// Field to filter on (e.g., "Title", "Content"). - /// Query string to filter for. - /// Field to sort by (e.g., "Title", "DateCreated"). - /// Sort direction (true = ascending, false = descending). Default is true. - /// Page number. - /// Number of records per page. - /// A list of filtered and paginated blog posts. - [HttpGet] - //[Authorize(Roles = "Reader,Writer,Admin")] - public async Task GetBlogPosts( - [FromQuery] string? filterOn, - [FromQuery] string? filterQuery, - [FromQuery] string? sortBy, - [FromQuery] bool? isAscending, - [FromQuery] int pageNumber = 1, - [FromQuery] int pageSize = 20) - { - var blogPosts = await repository.GetAllAsync( - filterOn, - filterQuery, - sortBy, - isAscending ?? true, - pageNumber, - pageSize); + var blogPostsDto = mapper.Map>(blogPosts); - var blogPostsDto = mapper.Map>(blogPosts); + return Ok(blogPostsDto); + } - return Ok(blogPostsDto); - } + /// + /// Gets a single blog post by its ID. + /// + /// The unique ID (GUID) of the blog post to get. + /// The found blog post DTO. + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostDto))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetBlogPost(Guid id) + { + var blogPost = await repository.GetByIdAsync(id); + if (blogPost == null) return NotFound(); + var blogPostDto = mapper.Map(blogPost); + return Ok(blogPostDto); + } - /// - /// Gets a single blog post by its ID. - /// - /// The unique ID (GUID) of the blog post to get. - /// The found blog post DTO. - [HttpGet("{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostDto))] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetBlogPost(Guid id) + /// + /// Creates a new blog post with an image (multipart/form-data). + /// + /// The form data containing required fields (Title, Content, Image) to create the blog post. + /// The newly created blog post. + /// Blog post created successfully. + /// Model validation failed (e.g., title is missing). + /// User is not authenticated. + /// User does not have the required role (not Writer or Admin). + [HttpPost("with-image")] + [ValidateModel] + [Authorize(Roles = "Writer,Admin")] + [Consumes("multipart/form-data")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(BlogPostDto))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task CreateBlogPost([FromForm] CreateBlogPostDto dto) + { + // [Q]: did you skip error handling because if you're here it means the user is authenticated, correct? + var userId = User.GetUserId(); + + var blogPost = new BlogPost + { + Title = dto.Title, + Content = dto.Content, + ApplicationUserId = userId + }; + + if (dto.Image != null && dto.Image.Length > 0) + { + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images"); + // FIXME: always put curly braces because it's error-prone. + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var uniqueFileName = Guid.NewGuid().ToString() + Path.GetExtension(dto.Image.FileName); + var filePath = Path.Combine(uploadsFolder, uniqueFileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) { - var blogPost = await repository.GetByIdAsync(id); - if (blogPost == null) return NotFound(); - var blogPostDto = mapper.Map(blogPost); - return Ok(blogPostDto); + await dto.Image.CopyToAsync(stream); } - /// - /// Creates a new blog post with an image (multipart/form-data). - /// - /// The form data containing required fields (Title, Content, Image) to create the blog post. - /// The newly created blog post. - /// Blog post created successfully. - /// Model validation failed (e.g., title is missing). - /// User is not authenticated. - /// User does not have the required role (not Writer or Admin). - [HttpPost("with-image")] - [ValidateModel] - [Authorize(Roles = "Writer,Admin")] - [Consumes("multipart/form-data")] - [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(BlogPostDto))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task CreateBlogPost([FromForm] CreateBlogPostDto dto) - { - var userId = User.GetUserId(); - - var blogPost = new BlogPost - { - Title = dto.Title, - Content = dto.Content, - ApplicationUserId = userId - }; - - if (dto.Image != null && dto.Image.Length > 0) - { - var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images"); - if (!Directory.Exists(uploadsFolder)) - Directory.CreateDirectory(uploadsFolder); - - var uniqueFileName = Guid.NewGuid().ToString() + Path.GetExtension(dto.Image.FileName); - var filePath = Path.Combine(uploadsFolder, uniqueFileName); + blogPost.ImageUrl = $"/images/{uniqueFileName}"; + } - using (var stream = new FileStream(filePath, FileMode.Create)) - { - await dto.Image.CopyToAsync(stream); - } - - blogPost.ImageUrl = $"/images/{uniqueFileName}"; - } - - var created = await repository.CreateAsync(blogPost); - - var createdDto = mapper.Map(created); - return CreatedAtAction(nameof(GetBlogPost), new { id = created.Id }, createdDto); - } + var created = await repository.CreateAsync(blogPost); - /// - /// Updates an existing blog post. - /// - /// The ID of the blog post to update. - /// The JSON body containing the updated data. - /// The updated blog post DTO. - [HttpPut("{id}")] - [ValidateModel] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostDto))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateBlogPost(Guid id, [FromBody] UpdateBlogPostDto updateBlogPostDto) - { - if (updateBlogPostDto == null) - return BadRequest("Blog Post is null"); - - var existingBlogPost = await repository.GetByIdAsync(id); - if (existingBlogPost == null) - return NotFound(); - - var userId = User.GetUserId(); - - if (existingBlogPost.ApplicationUserId != userId) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); + var createdDto = mapper.Map(created); + return CreatedAtAction(nameof(GetBlogPost), new { id = created.Id }, createdDto); + } - var blogPost = mapper.Map(updateBlogPostDto); - blogPost.ApplicationUserId = userId; + /// + /// Updates an existing blog post. + /// + /// The ID of the blog post to update. + /// The JSON body containing the updated data. + /// The updated blog post DTO. + [HttpPut("{id}")] + [ValidateModel] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPostDto))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateBlogPost(Guid id, [FromBody] UpdateBlogPostDto updateBlogPostDto) + { + if (updateBlogPostDto == null) + return BadRequest("Blog Post is null"); - var updatedBlogPost = await repository.UpdateAsync(id, blogPost); - if (updatedBlogPost == null) - return NotFound(); + var existingBlogPost = await repository.GetByIdAsync(id); + if (existingBlogPost == null) + return NotFound(); - var updatedBlogPostDto = mapper.Map(updatedBlogPost); - return Ok(updatedBlogPostDto); - } + var userId = User.GetUserId(); - /// - /// Deletes a blog post. - /// - /// The ID of the blog post to delete. - /// Returns no content. - [HttpDelete("{id}")] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteBlogPost(Guid id) - { - var userId = User.GetUserId(); - var blogPost = await repository.GetByIdAsync(id); + if (existingBlogPost.ApplicationUserId != userId) + // FIXME: you can be more explicit and say "action not allowed. You can add only your blog posts." + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); - if (blogPost == null) - return NotFound("Blog is null"); + var blogPost = mapper.Map(updateBlogPostDto); + blogPost.ApplicationUserId = userId; - if (!User.IsInRole("Admin") && blogPost.ApplicationUserId != userId) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); + var updatedBlogPost = await repository.UpdateAsync(id, blogPost); + if (updatedBlogPost == null) + return NotFound(); - await repository.DeleteAsync(blogPost.Id); - return NoContent(); - } - - /// - /// Uploads a cover image for an existing blog post (multipart/form-data). - /// - /// The ID of the blog post to upload the image for. - /// The image file to upload. - /// An object containing the new URL of the uploaded image. - /// Image uploaded successfully and its URL is returned. - /// No file was uploaded. - /// User is not authenticated. - /// User is not the owner of this blog post. - /// Blog post with the specified ID was not found. - [HttpPost("{id}/upload-image")] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UploadImage(Guid id, IFormFile imageFile) - { - var blogPost = await repository.GetByIdAsync(id); - if (blogPost == null) - return NotFound("Blog post not found."); - - var userId = User.GetUserId(); - if (blogPost.ApplicationUserId != userId) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); + var updatedBlogPostDto = mapper.Map(updatedBlogPost); + return Ok(updatedBlogPostDto); + } - if (imageFile == null || imageFile.Length == 0) - return BadRequest("No image file uploaded."); + /// + /// Deletes a blog post. + /// + /// The ID of the blog post to delete. + /// Returns no content. + [HttpDelete("{id}")] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteBlogPost(Guid id) + { + var userId = User.GetUserId(); + var blogPost = await repository.GetByIdAsync(id); - var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images"); - if (!Directory.Exists(uploadsFolder)) - Directory.CreateDirectory(uploadsFolder); + if (blogPost == null) + // FIXME: "resource does not exist" => it can be the generic way of saying this. + return NotFound("Blog is null"); - var uniqueFileName = Guid.NewGuid().ToString() + Path.GetExtension(imageFile.FileName); - var filePath = Path.Combine(uploadsFolder, uniqueFileName); + if (!User.IsInRole("Admin") && blogPost.ApplicationUserId != userId) + // FIXME: same as before + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); - using (var stream = new FileStream(filePath, FileMode.Create)) - { - await imageFile.CopyToAsync(stream); - } + await repository.DeleteAsync(blogPost.Id); + return NoContent(); + } - blogPost.ImageUrl = $"/images/{uniqueFileName}"; - await repository.UpdateAsync(id, blogPost); - return Ok(new { imageUrl = blogPost.ImageUrl }); - } + /// + /// Uploads a cover image for an existing blog post (multipart/form-data). + /// + /// The ID of the blog post to upload the image for. + /// The image file to upload. + /// An object containing the new URL of the uploaded image. + /// Image uploaded successfully and its URL is returned. + /// No file was uploaded. + /// User is not authenticated. + /// User is not the owner of this blog post. + /// Blog post with the specified ID was not found. + [HttpPost("{id}/upload-image")] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UploadImage(Guid id, IFormFile imageFile) + { + var blogPost = await repository.GetByIdAsync(id); + if (blogPost == null) + return NotFound("Blog post not found."); + + // FIXME: this check can be enforced in a couple of ways (which I believe are better): + // 1. add this in a filter decorating the controller + // 2. add this as a fixed condition in all the queries/commands you're performing + var userId = User.GetUserId(); + if (blogPost.ApplicationUserId != userId) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); + + if (imageFile == null || imageFile.Length == 0) + return BadRequest("No image file uploaded."); + + // FIXME: this check can be performed on the startup of the program, not on every call + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images"); + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var uniqueFileName = Guid.NewGuid().ToString() + Path.GetExtension(imageFile.FileName); + var filePath = Path.Combine(uploadsFolder, uniqueFileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await imageFile.CopyToAsync(stream); + } + + blogPost.ImageUrl = $"/images/{uniqueFileName}"; + await repository.UpdateAsync(id, blogPost); + return Ok(new { imageUrl = blogPost.ImageUrl }); } + } } \ No newline at end of file diff --git a/API/Controllers/CommentsController.cs b/API/Controllers/CommentsController.cs index 4e0387f..710d37f 100644 --- a/API/Controllers/CommentsController.cs +++ b/API/Controllers/CommentsController.cs @@ -8,197 +8,201 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using API.Extensions; -using System.Collections.Generic; -using System; +using System.Collections.Generic; +using System; namespace API.Controllers { - [Route("api/[controller]")] - [ApiController] - [Produces("application/json")] // Controller'ın varsayılan olarak JSON döndürdüğünü belirtir - public class CommentsController : ControllerBase + [Route("api/[controller]")] + [ApiController] + [Produces("application/json")] // Controller'ın varsayılan olarak JSON döndürdüğünü belirtir + public class CommentsController : ControllerBase + { + private readonly ICommentRepository repository; + private readonly IMapper mapper; + // FIXME: the cache seems to not be used. + private readonly IMemoryCache memoryCache; + + public CommentsController(ICommentRepository repository, IMapper mapper, IMemoryCache memoryCache) { - private readonly ICommentRepository repository; - private readonly IMapper mapper; - private readonly IMemoryCache memoryCache; - - public CommentsController(ICommentRepository repository, IMapper mapper, IMemoryCache memoryCache) - { - this.repository = repository; - this.mapper = mapper; - this.memoryCache = memoryCache; - } - - /// - /// Gets all comments with filtering, sorting, and pagination. - /// - /// Field to filter on (e.g., "Content"). - /// Query string to filter for. - /// Field to sort by (e.g., "DateCreated"). - /// Sort direction (true = ascending, false = descending). Default is true. - /// Page number. Default is 1. - /// Number of records per page. Default is 20. - /// A list of filtered and paginated comments. - [HttpGet] - [Authorize(Roles = "Reader,Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task GetComments( + this.repository = repository; + this.mapper = mapper; + this.memoryCache = memoryCache; + } + + // FIXME: to me, it doesn't make any sense this endpoint. What is the point of seeing comments mixed from one blog post and another? + // TODO: it's better to have "CommentsByUser" and "CommentsByBlogPost" + /// + /// Gets all comments with filtering, sorting, and pagination. + /// + /// Field to filter on (e.g., "Content"). + /// Query string to filter for. + /// Field to sort by (e.g., "DateCreated"). + /// Sort direction (true = ascending, false = descending). Default is true. + /// Page number. Default is 1. + /// Number of records per page. Default is 20. + /// A list of filtered and paginated comments. + [HttpGet] + [Authorize(Roles = "Reader,Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetComments( [FromQuery] string? filterOn, [FromQuery] string? filterQuery, [FromQuery] string? sortBy, [FromQuery] bool? isAscending, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) - { - var comments = await repository.GetAllAsync( - filterOn, - filterQuery, - sortBy, - isAscending ?? true, - pageNumber, - pageSize); - - var commentDto = mapper.Map>(comments); - return Ok(commentDto); - } - - /// - /// Gets a single comment by its ID. - /// - /// The unique ID (GUID) of the comment to get. - /// The found comment DTO. - [HttpGet("{id}")] - [Authorize(Roles = "Reader,Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CommentDto))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetComment(Guid id) - { - var comment = await repository.GetByIdAsync(id); - if (comment == null) return NotFound(); - var commentDto = mapper.Map(comment); - return Ok(commentDto); - } - - /// - /// Creates a new comment. - /// - /// The JSON body containing data to create the comment. - /// The newly created comment. - /// Comment created successfully. - /// Model validation failed or the request body is null. - /// User is not authenticated. - /// User does not have the required role (not Writer or Admin). - [HttpPost] - [ValidateModel] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(CommentDto))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task CreateComment([FromBody] CreateCommentDto createCommentDto) - { - if (createCommentDto == null) return BadRequest("Comment is null"); - - var userId = User.GetUserId(); // User ID from JWT - var comment = mapper.Map(createCommentDto); - comment.ApplicationUserId = userId; - - var createdComment = await repository.CreateAsync(comment); - var createdCommentDto = mapper.Map(createdComment); - return CreatedAtAction(nameof(GetComment), new { id = createdCommentDto.Id }, createdCommentDto); - } - - /// - /// Updates an existing comment. - /// - /// The ID of the comment to update. - /// The JSON body containing the updated data. - /// The updated comment DTO. - /// Comment updated successfully. - /// Model validation failed or the request body is null. - /// User is not authenticated. - /// User is not the owner of this comment. - /// Comment with the specified ID was not found. - [HttpPut("{id}")] - [ValidateModel] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CommentDto))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateComment(Guid id, [FromBody] UpdateCommentDto updateCommentDto) - { - if (updateCommentDto == null) - return BadRequest("Comment is null"); - - var existingComment = await repository.GetByIdAsync(id); - if (existingComment == null) - return NotFound(); - - var userId = User.GetUserId(); - if (existingComment.ApplicationUserId != userId) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); - - var comment = mapper.Map(updateCommentDto); - - // We get the User ID from the token, as it's not in the DTO - comment.ApplicationUserId = userId; - - var updatedComment = await repository.UpdateAsync(id, comment); - if (updatedComment == null) - return NotFound(); - - var updatedCommentDto = mapper.Map(updatedComment); - return Ok(updatedCommentDto); - } - - /// - /// Deletes a comment. - /// - /// The ID of the comment to delete. - /// Returns no content. - /// Comment deleted successfully. - /// User is not authenticated. - /// User is not the owner of the comment or not an Admin. - /// Comment with the specified ID was not found. - [HttpDelete("{id}")] - [Authorize(Roles = "Writer,Admin")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteComment(Guid id) - { - var userId = User.GetUserId(); - var comment = await repository.GetByIdAsync(id); - - if (comment == null) - return NotFound("Comment is null"); - - if (!User.IsInRole("Admin") && comment.ApplicationUserId != userId) - return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized"); - - await repository.DeleteAsync(comment.Id); - return NoContent(); - } - - /// - /// Gets all comments for a specific blog post. - /// - /// The ID of the blog post to retrieve comments for. - /// A list of comments for the specified blog post. - [HttpGet("byblogpost/{blogPostId}")] - [Authorize(Roles = "Reader,Writer,Admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task GetCommentsByBlogPostId(Guid blogPostId) - { - var comments = await repository.GetByBlogPostIdAsync(blogPostId); - var commentDtos = mapper.Map>(comments); - return Ok(commentDtos); - } + { + var comments = await repository.GetAllAsync( + filterOn, + filterQuery, + sortBy, + isAscending ?? true, + pageNumber, + pageSize); + + var commentDto = mapper.Map>(comments); + return Ok(commentDto); + } + + /// + /// Gets a single comment by its ID. + /// + /// The unique ID (GUID) of the comment to get. + /// The found comment DTO. + [HttpGet("{id}")] + [Authorize(Roles = "Reader,Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CommentDto))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetComment(Guid id) + { + var comment = await repository.GetByIdAsync(id); + if (comment == null) return NotFound(); + var commentDto = mapper.Map(comment); + return Ok(commentDto); + } + + /// + /// Creates a new comment. + /// + /// The JSON body containing data to create the comment. + /// The newly created comment. + /// Comment created successfully. + /// Model validation failed or the request body is null. + /// User is not authenticated. + /// User does not have the required role (not Writer or Admin). + [HttpPost] + [ValidateModel] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(CommentDto))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task CreateComment([FromBody] CreateCommentDto createCommentDto) + { + if (createCommentDto == null) return BadRequest("Comment is null"); + + var userId = User.GetUserId(); // User ID from JWT + var comment = mapper.Map(createCommentDto); + comment.ApplicationUserId = userId; + + var createdComment = await repository.CreateAsync(comment); + var createdCommentDto = mapper.Map(createdComment); + return CreatedAtAction(nameof(GetComment), new { id = createdCommentDto.Id }, createdCommentDto); + } + + /// + /// Updates an existing comment. + /// + /// The ID of the comment to update. + /// The JSON body containing the updated data. + /// The updated comment DTO. + /// Comment updated successfully. + /// Model validation failed or the request body is null. + /// User is not authenticated. + /// User is not the owner of this comment. + /// Comment with the specified ID was not found. + [HttpPut("{id}")] + [ValidateModel] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CommentDto))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateComment(Guid id, [FromBody] UpdateCommentDto updateCommentDto) + { + // FIXME: same observations I did before. + if (updateCommentDto == null) + return BadRequest("Comment is null"); + + var existingComment = await repository.GetByIdAsync(id); + if (existingComment == null) + return NotFound(); + + var userId = User.GetUserId(); + if (existingComment.ApplicationUserId != userId) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized."); + + var comment = mapper.Map(updateCommentDto); + + // We get the User ID from the token, as it's not in the DTO + comment.ApplicationUserId = userId; + + var updatedComment = await repository.UpdateAsync(id, comment); + if (updatedComment == null) + return NotFound(); + + var updatedCommentDto = mapper.Map(updatedComment); + return Ok(updatedCommentDto); + } + + /// + /// Deletes a comment. + /// + /// The ID of the comment to delete. + /// Returns no content. + /// Comment deleted successfully. + /// User is not authenticated. + /// User is not the owner of the comment or not an Admin. + /// Comment with the specified ID was not found. + [HttpDelete("{id}")] + [Authorize(Roles = "Writer,Admin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteComment(Guid id) + { + var userId = User.GetUserId(); + var comment = await repository.GetByIdAsync(id); + + if (comment == null) + return NotFound("Comment is null"); + + if (!User.IsInRole("Admin") && comment.ApplicationUserId != userId) + return StatusCode(StatusCodes.Status403Forbidden, "Unauthorized"); + + await repository.DeleteAsync(comment.Id); + return NoContent(); + } + + /// + /// Gets all comments for a specific blog post. + /// + /// The ID of the blog post to retrieve comments for. + /// A list of comments for the specified blog post. + [HttpGet("byblogpost/{blogPostId}")] + [Authorize(Roles = "Reader,Writer,Admin")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetCommentsByBlogPostId(Guid blogPostId) + { + var comments = await repository.GetByBlogPostIdAsync(blogPostId); + var commentDtos = mapper.Map>(comments); + return Ok(commentDtos); } + } } \ No newline at end of file diff --git a/API/Middlewares/ExceptionHandlerMiddleware.cs b/API/Middlewares/ExceptionHandlerMiddleware.cs index ceb037c..e67bef2 100644 --- a/API/Middlewares/ExceptionHandlerMiddleware.cs +++ b/API/Middlewares/ExceptionHandlerMiddleware.cs @@ -2,40 +2,57 @@ namespace API.Middlewares { - public class ExceptionHandlerMiddleware + public class ExceptionHandlerMiddleware + { + private readonly ILogger logger; + private readonly RequestDelegate next; + + public ExceptionHandlerMiddleware(ILogger logger, RequestDelegate next) { - private readonly ILogger logger; - private readonly RequestDelegate next; + this.logger = logger; + this.next = next; + } - public ExceptionHandlerMiddleware(ILogger logger, RequestDelegate next) - { - this.logger = logger; - this.next = next; - } + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await next(httpContext); + } + catch (Exception ex) + { + // FIXME: why are we setting the generic 500 error on everything? + // I tried to register a user with wrong fields, which was clearly a 400 BadRequest but I got 500. This is the CURL: - public async Task InvokeAsync(HttpContext httpContext) + // curl -X 'POST' \ + // 'http://localhost:5016/api/Auth/Register' \ + // -H 'accept: */ + // *' \ + // -H 'Content - Type: application / json' \ + // -d '{ + // "username": "user@example.com", + // "password": "string", + // "roles": [ + // "string" + // ], + // "firstname": "string", + // "lastname": "string" + // }' + var errorId = Guid.NewGuid(); + logger.LogError(ex, "ErrorId: {ErrorId} - {Message}", errorId, ex.Message); + + httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + httpContext.Response.ContentType = "application/json"; + + var error = new { - try - { - await next(httpContext); - } - catch (Exception ex) - { - var errorId = Guid.NewGuid(); - logger.LogError(ex, "ErrorId: {ErrorId} - {Message}", errorId, ex.Message); - - httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - httpContext.Response.ContentType = "application/json"; - - var error = new - { - Id = errorId, - ErrorMessage = "Something went wrong" - }; - await httpContext.Response.WriteAsJsonAsync(error); - } - - } + Id = errorId, + ErrorMessage = "Something went wrong" + }; + await httpContext.Response.WriteAsJsonAsync(error); + } + } + } } diff --git a/API/Program.cs b/API/Program.cs index 6757029..3045058 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,125 +1,126 @@ - using API.Mappings; - using API.Middlewares; - using DataAccess; - using DataAccess.Abstract; - using DataAccess.Concrete; - using Entities; - using Microsoft.AspNetCore.Authentication.JwtBearer; - using Microsoft.AspNetCore.Identity; - using Microsoft.EntityFrameworkCore; - using Microsoft.IdentityModel.Tokens; - using Serilog; - using Microsoft.OpenApi.Models; +using API.Mappings; +using API.Middlewares; +using DataAccess; +using DataAccess.Abstract; +using DataAccess.Concrete; +using Entities; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Serilog; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); - var logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.Console() - .WriteTo.File("Logs/log.txt", rollingInterval: RollingInterval.Day) - .CreateLogger(); +var logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File("Logs/log.txt", rollingInterval: RollingInterval.Day) + .CreateLogger(); - builder.Services.AddCors(options => - { - options.AddPolicy("AllowLocalhost5173", - policy => policy.WithOrigins("http://localhost:5173") - .AllowAnyHeader() - .AllowAnyMethod()); - }); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowLocalhost5173", + policy => policy.WithOrigins("http://localhost:5173") + .AllowAnyHeader() + .AllowAnyMethod()); +}); - builder.Logging.ClearProviders(); - builder.Logging.AddSerilog(logger); +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(logger); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(opt => - opt.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) - } - ); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opt => + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + // FIXME: fix this warning, in general all of the other warnings must be treated as errors. + IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) + } + ); - builder.Services.AddAuthorization(options => - { - options.AddPolicy("Reader", policy => policy.RequireRole("Reader")); - options.AddPolicy("Writer", policy => policy.RequireRole("Writer")); - options.AddPolicy("Admin", policy => policy.RequireRole("Admin")); - }); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("Reader", policy => policy.RequireRole("Reader")); + options.AddPolicy("Writer", policy => policy.RequireRole("Writer")); + options.AddPolicy("Admin", policy => policy.RequireRole("Admin")); +}); - builder.Services.Configure(options => - { - options.Password.RequireDigit = false; - options.Password.RequiredLength = 6; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequireUppercase = false; - options.Password.RequireLowercase = false; - options.Password.RequiredUniqueChars = 1; +builder.Services.Configure(options => +{ + options.Password.RequireDigit = false; + options.Password.RequiredLength = 6; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequireLowercase = false; + options.Password.RequiredUniqueChars = 1; - }); +}); - builder.Services.AddMemoryCache(); - builder.Services.AddAutoMapper(typeof(AutoMapperProfiles)); +builder.Services.AddMemoryCache(); +builder.Services.AddAutoMapper(typeof(AutoMapperProfiles)); - builder.Services.AddControllers(); - builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { - options.SwaggerDoc("v1", new OpenApiInfo + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Blog Web API", + Version = "v1", + Description = "This API provides the core backend services for a blog platform. " + + "It manages user registration (Register) and login (Login) operations. " + + "Authenticated users can create new blog posts (`BlogPostsController`) or comments (`CommentsController`), " + + "and update or delete their own content. " + + "Users with the Admin role can delete any content.", + Contact = new OpenApiContact { - Title = "Blog Web API", - Version = "v1", - Description = "This API provides the core backend services for a blog platform. " + - "It manages user registration (Register) and login (Login) operations. " + - "Authenticated users can create new blog posts (`BlogPostsController`) or comments (`CommentsController`), " + - "and update or delete their own content. " + - "Users with the Admin role can delete any content.", - Contact = new OpenApiContact - { - Name = "Batuhan Aksut", - Email = "batuhanaksut@hotmail.com", - Url = new Uri("https://github.com/batuaksut") - }, - License = new OpenApiLicense - { - Name = "MIT License", - Url = new Uri("https://opensource.org/licenses/MIT") - } - }); - - options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + Name = "Batuhan Aksut", + Email = "batuhanaksut@hotmail.com", + Url = new Uri("https://github.com/batuaksut") + }, + License = new OpenApiLicense { - In = Microsoft.OpenApi.Models.ParameterLocation.Header, - Description = "Please enter 'Bearer' followed by a space and then your JWT token.\n\r\rExample: 'Bearer 12345abcdef'", - Name = "Authorization", - Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, - Scheme = "Bearer", - BearerFormat = "JWT" - }); - - options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement + Name = "MIT License", + Url = new Uri("https://opensource.org/licenses/MIT") + } + }); + + options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + { + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Description = "Please enter 'Bearer' followed by a space and then your JWT token.\n\r\rExample: 'Bearer 12345abcdef'", + Name = "Authorization", + Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT" + }); + + options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement { { new Microsoft.OpenApi.Models.OpenApiSecurityScheme @@ -134,42 +135,42 @@ } }); - var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - options.IncludeXmlComments(xmlPath); + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + options.IncludeXmlComments(xmlPath); }); builder.Services.AddDataProtection(); - builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("BlogAuthConnection"))); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("BlogAuthConnection"))); - builder.Services.AddIdentityCore() - .AddRoles>() - .AddTokenProvider>("Blog") - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); +builder.Services.AddIdentityCore() + .AddRoles>() + .AddTokenProvider>("Blog") + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); - var app = builder.Build(); +var app = builder.Build(); - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} - app.UseMiddleware(); - app.UseHttpsRedirection(); - app.UseStaticFiles(); +app.UseMiddleware(); +app.UseHttpsRedirection(); +app.UseStaticFiles(); - app.UseCors("AllowLocalhost5173"); - app.UseAuthentication(); - app.UseAuthorization(); +app.UseCors("AllowLocalhost5173"); +app.UseAuthentication(); +app.UseAuthorization(); - app.MapControllers(); +app.MapControllers(); - app.Run(); +app.Run(); diff --git a/DataAccess/Concrete/BlogPostRepository.cs b/DataAccess/Concrete/BlogPostRepository.cs index 8c47041..d2d5847 100644 --- a/DataAccess/Concrete/BlogPostRepository.cs +++ b/DataAccess/Concrete/BlogPostRepository.cs @@ -9,116 +9,118 @@ namespace DataAccess.Concrete { - public class BlogPostRepository:IBlogPostRepository - { - private readonly AppDbContext context; + public class BlogPostRepository : IBlogPostRepository + { + private readonly AppDbContext context; - public BlogPostRepository(AppDbContext context) - { - this.context = context; - } + public BlogPostRepository(AppDbContext context) + { + this.context = context; + } - public async Task CreateAsync(BlogPost blogPost) - { + public async Task CreateAsync(BlogPost blogPost) + { - var newBlogPost = new BlogPost - { - Title = blogPost.Title, - Content = blogPost.Content, - ApplicationUserId = blogPost.ApplicationUserId, - ImageUrl=blogPost.ImageUrl + var newBlogPost = new BlogPost + { + Title = blogPost.Title, + Content = blogPost.Content, + ApplicationUserId = blogPost.ApplicationUserId, + ImageUrl = blogPost.ImageUrl - }; + }; - await context.BlogPosts.AddAsync(newBlogPost); - await context.SaveChangesAsync(); - return newBlogPost; - } + await context.BlogPosts.AddAsync(newBlogPost); + await context.SaveChangesAsync(); + return newBlogPost; + } - public async Task DeleteAsync(Guid id) - { - var blogPostToDelete = await context.BlogPosts.Include(x => x.ApplicationUser).Include(x => x.Comments).FirstOrDefaultAsync(x => x.Id == id); - if (blogPostToDelete == null) return null; + public async Task DeleteAsync(Guid id) + { + var blogPostToDelete = await context.BlogPosts.Include(x => x.ApplicationUser).Include(x => x.Comments).FirstOrDefaultAsync(x => x.Id == id); + if (blogPostToDelete == null) return null; - context.BlogPosts.Remove(blogPostToDelete); - await context.SaveChangesAsync(); - return blogPostToDelete; - } + context.BlogPosts.Remove(blogPostToDelete); + await context.SaveChangesAsync(); + return blogPostToDelete; + } - public async Task> GetAllAsync( - string? filterOn = null, - string? filterQuery = null, - string? sortBy = null, - bool isAscending = true, - int pageNumber = 1, - int pageSize = 20) - { - // Maksimum pageSize sınırı koyduk - pageSize = Math.Min(pageSize, 100); - - var blogPosts = context.BlogPosts - .Include(x => x.ApplicationUser) - .Include(x => x.Comments) - .ThenInclude(c => c.ApplicationUser) - .AsQueryable(); - - // Filtering - if (!string.IsNullOrWhiteSpace(filterOn) && !string.IsNullOrWhiteSpace(filterQuery)) - { - switch (filterOn.ToLower()) - { - case "title": - blogPosts = blogPosts.Where(x => x.Title.Contains(filterQuery)); - break; - case "content": - blogPosts = blogPosts.Where(x => x.Content.Contains(filterQuery)); - break; - } - } - - // Sorting - if (!string.IsNullOrWhiteSpace(sortBy)) - { - blogPosts = sortBy.ToLower() switch - { - "title" => isAscending ? blogPosts.OrderBy(x => x.Title) : blogPosts.OrderByDescending(x => x.Title), - _ => blogPosts - }; - } - - // Pagination - var skipResults = (pageNumber - 1) * pageSize; - return await blogPosts.Skip(skipResults).Take(pageSize).ToListAsync(); - } - public async Task GetByIdAsync(Guid id) + // NICETOHAVE: evaluate if you can use something like the Sieve model to not do everything by hand. + public async Task> GetAllAsync( +string? filterOn = null, +string? filterQuery = null, +string? sortBy = null, +bool isAscending = true, +int pageNumber = 1, +int pageSize = 20) + { + // Maksimum pageSize sınırı koyduk + pageSize = Math.Min(pageSize, 100); + + var blogPosts = context.BlogPosts + .Include(x => x.ApplicationUser) + .Include(x => x.Comments) + .ThenInclude(c => c.ApplicationUser) + .AsQueryable(); + + // Filtering + if (!string.IsNullOrWhiteSpace(filterOn) && !string.IsNullOrWhiteSpace(filterQuery)) + { + switch (filterOn.ToLower()) { - return await context.BlogPosts - .Include(x => x.ApplicationUser) - .Include(x => x.Comments) - .ThenInclude(c => c.ApplicationUser) - .FirstOrDefaultAsync(x => x.Id == id); + case "title": + blogPosts = blogPosts.Where(x => x.Title.Contains(filterQuery)); + break; + case "content": + blogPosts = blogPosts.Where(x => x.Content.Contains(filterQuery)); + break; } + } - public async Task UpdateAsync(Guid id, BlogPost blogPost) + // Sorting + if (!string.IsNullOrWhiteSpace(sortBy)) + { + blogPosts = sortBy.ToLower() switch { - var blogPostToUpdate = await context.BlogPosts.FirstOrDefaultAsync(x => x.Id == id); - if (blogPostToUpdate == null) return null; + "title" => isAscending ? blogPosts.OrderBy(x => x.Title) : blogPosts.OrderByDescending(x => x.Title), + _ => blogPosts + }; + } + + // Pagination + var skipResults = (pageNumber - 1) * pageSize; + return await blogPosts.Skip(skipResults).Take(pageSize).ToListAsync(); + } + public async Task GetByIdAsync(Guid id) + { + return await context.BlogPosts + .Include(x => x.ApplicationUser) + .Include(x => x.Comments) + .ThenInclude(c => c.ApplicationUser) + .FirstOrDefaultAsync(x => x.Id == id); + } - // Kullanıcı gerçekten var mı kontrolü - var userExists = await context.Users.AnyAsync(x => x.Id == blogPost.ApplicationUserId); - if (!userExists) - throw new Exception("ApplicationUser does not exist."); + public async Task UpdateAsync(Guid id, BlogPost blogPost) + { + var blogPostToUpdate = await context.BlogPosts.FirstOrDefaultAsync(x => x.Id == id); + if (blogPostToUpdate == null) return null; - blogPostToUpdate.Title = blogPost.Title; - blogPostToUpdate.Content = blogPost.Content; - blogPostToUpdate.ApplicationUserId = blogPost.ApplicationUserId; - blogPostToUpdate.ImageUrl = blogPost.ImageUrl ?? blogPostToUpdate.ImageUrl; + // Kullanıcı gerçekten var mı kontrolü + // FIXME: this check can be done before. You're only allowed to edit your own blog posts, isn't it? + var userExists = await context.Users.AnyAsync(x => x.Id == blogPost.ApplicationUserId); + if (!userExists) + throw new Exception("ApplicationUser does not exist."); - context.BlogPosts.Update(blogPostToUpdate); - await context.SaveChangesAsync(); + blogPostToUpdate.Title = blogPost.Title; + blogPostToUpdate.Content = blogPost.Content; + blogPostToUpdate.ApplicationUserId = blogPost.ApplicationUserId; + blogPostToUpdate.ImageUrl = blogPost.ImageUrl ?? blogPostToUpdate.ImageUrl; - return blogPostToUpdate; - } + context.BlogPosts.Update(blogPostToUpdate); + await context.SaveChangesAsync(); + + return blogPostToUpdate; } + } } diff --git a/DataAccess/Concrete/CommentRepository.cs b/DataAccess/Concrete/CommentRepository.cs index d3a00fe..10726c7 100644 --- a/DataAccess/Concrete/CommentRepository.cs +++ b/DataAccess/Concrete/CommentRepository.cs @@ -9,118 +9,124 @@ namespace DataAccess.Concrete { - public class CommentRepository : ICommentRepository - { - private readonly AppDbContext context; + public class CommentRepository : ICommentRepository + { + private readonly AppDbContext context; - public CommentRepository(AppDbContext context) - { - this.context = context; - } - public async Task CreateAsync(Comment comment) - { - var commentToAdd = new Comment - { - BlogPostId = comment.BlogPostId, - Content = comment.Content, - ApplicationUserId = comment.ApplicationUserId, - CreatedAt=comment.CreatedAt - }; - - await context.Comments.AddAsync(commentToAdd); - await context.SaveChangesAsync(); - - return await context.Comments - .Include(c => c.ApplicationUser) - .Include(c => c.BlogPost) - .FirstOrDefaultAsync(c => c.Id == commentToAdd.Id); - } + public CommentRepository(AppDbContext context) + { + this.context = context; + } + public async Task CreateAsync(Comment comment) + { + var commentToAdd = new Comment + { + BlogPostId = comment.BlogPostId, + Content = comment.Content, + ApplicationUserId = comment.ApplicationUserId, + // FIXME: "CreatedAt" should be automatically set. + CreatedAt = comment.CreatedAt + }; + + await context.Comments.AddAsync(commentToAdd); + await context.SaveChangesAsync(); + + // FIXME: warning + return await context.Comments + .Include(c => c.ApplicationUser) + .Include(c => c.BlogPost) + .FirstOrDefaultAsync(c => c.Id == commentToAdd.Id); + } - public async Task DeleteAsync(Guid id) - { - var commentToDelete = await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); - if (commentToDelete == null) return null; + public async Task DeleteAsync(Guid id) + { + var commentToDelete = await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); + if (commentToDelete == null) return null; - context.Comments.Remove(commentToDelete); - await context.SaveChangesAsync(); - return commentToDelete; - } + context.Comments.Remove(commentToDelete); + await context.SaveChangesAsync(); + return commentToDelete; + } - public async Task> GetAllAsync( - string? filterOn = null, - string? filterQuery = null, - string? sortBy = null, - bool isAscending = true, - int pageNumber = 1, - int pageSize = 20) + public async Task> GetAllAsync( +string? filterOn = null, +string? filterQuery = null, +string? sortBy = null, +bool isAscending = true, +int pageNumber = 1, +int pageSize = 20) + { + // Maksimum pageSize sınırı koyduk + pageSize = Math.Min(pageSize, 100); + + var comments = context.Comments + .Include(x => x.ApplicationUser) + .Include(x => x.BlogPost) + .AsQueryable(); + + // Filtering + if (!string.IsNullOrWhiteSpace(filterOn) && !string.IsNullOrWhiteSpace(filterQuery)) + { + switch (filterOn.ToLower()) { - // Maksimum pageSize sınırı koyduk - pageSize = Math.Min(pageSize, 100); - - var comments = context.Comments - .Include(x => x.ApplicationUser) - .Include(x => x.BlogPost) - .AsQueryable(); - - // Filtering - if (!string.IsNullOrWhiteSpace(filterOn) && !string.IsNullOrWhiteSpace(filterQuery)) - { - switch (filterOn.ToLower()) - { - case "content": - comments = comments.Where(x => x.Content.Contains(filterQuery)); - break; - } - } - - // Sorting - if (!string.IsNullOrWhiteSpace(sortBy)) - { - comments = sortBy.ToLower() switch - { - "content" => isAscending ? comments.OrderBy(x => x.Content) : comments.OrderByDescending(x => x.Content), - _ => comments - }; - } - - // Pagination - var skipResults = (pageNumber - 1) * pageSize; - return await comments.Skip(skipResults).Take(pageSize).ToListAsync(); + case "content": + comments = comments.Where(x => x.Content.Contains(filterQuery)); + break; } + } - public async Task GetByIdAsync(Guid id) + // Sorting + if (!string.IsNullOrWhiteSpace(sortBy)) + { + comments = sortBy.ToLower() switch { - return await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); - } + "content" => isAscending ? comments.OrderBy(x => x.Content) : comments.OrderByDescending(x => x.Content), + _ => comments + }; + } + + // Pagination + var skipResults = (pageNumber - 1) * pageSize; + return await comments.Skip(skipResults).Take(pageSize).ToListAsync(); + } - public async Task UpdateAsync(Guid id, Comment comment) - { - var commentToUpdate = await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); + public async Task GetByIdAsync(Guid id) + { + // [Q]: Why you're always including User & BlogPost information? + // from a performance perspective is not ideal. We can save this discussion for the future BTW. + return await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); + } - if (commentToUpdate == null) return null; - var userExists = await context.Users.AnyAsync(x => x.Id == comment.ApplicationUserId); - var blogExists = await context.BlogPosts.AnyAsync(x => x.Id == comment.BlogPostId); + public async Task UpdateAsync(Guid id, Comment comment) + { + // [Q]: do you need to "Include" the other two entities? + var commentToUpdate = await context.Comments.Include(x => x.ApplicationUser).Include(x => x.BlogPost).FirstOrDefaultAsync(x => x.Id == id); - if (!userExists || !blogExists) - throw new Exception("User or BlogPost does not exist."); + if (commentToUpdate == null) return null; + var userExists = await context.Users.AnyAsync(x => x.Id == comment.ApplicationUserId); + var blogExists = await context.BlogPosts.AnyAsync(x => x.Id == comment.BlogPostId); - commentToUpdate.Content = comment.Content; - commentToUpdate.BlogPostId = comment.BlogPostId; - commentToUpdate.ApplicationUserId = comment.ApplicationUserId; - context.Comments.Update(commentToUpdate); - await context.SaveChangesAsync(); - return commentToUpdate; + // FIXME: make the single check to be detailed in the error message. + if (!userExists || !blogExists) + throw new Exception("User or BlogPost does not exist."); - } + commentToUpdate.Content = comment.Content; + commentToUpdate.BlogPostId = comment.BlogPostId; + commentToUpdate.ApplicationUserId = comment.ApplicationUserId; + context.Comments.Update(commentToUpdate); + await context.SaveChangesAsync(); + return commentToUpdate; - public async Task> GetByBlogPostIdAsync(Guid blogPostId) - { - return await context.Comments - .Include(x => x.ApplicationUser) - .Include(x => x.BlogPost) - .Where(x => x.BlogPostId == blogPostId) - .ToListAsync(); - } + } + public async Task> GetByBlogPostIdAsync(Guid blogPostId) + { + return await context.Comments + .Include(x => x.ApplicationUser) + .Include(x => x.BlogPost) + .Where(x => x.BlogPostId == blogPostId) + .ToListAsync(); } + + } } diff --git a/DataAccess/Concrete/TokenRepository.cs b/DataAccess/Concrete/TokenRepository.cs index f7e4bce..db234a0 100644 --- a/DataAccess/Concrete/TokenRepository.cs +++ b/DataAccess/Concrete/TokenRepository.cs @@ -13,39 +13,40 @@ namespace DataAccess.Concrete { - public class TokenRepository : ITokenRepository - { - private readonly IConfiguration configuration; + public class TokenRepository : ITokenRepository + { + private readonly IConfiguration configuration; - public TokenRepository(IConfiguration configuration) - { - this.configuration = configuration; - } - public string CreateJWTToken(ApplicationUser user, List roles) - { - var claims = new List + public TokenRepository(IConfiguration configuration) + { + this.configuration = configuration; + } + public string CreateJWTToken(ApplicationUser user, List roles) + { + // FIXME: warnings & expressions that can be simplified. + var claims = new List { new Claim(ClaimTypes.Email, user.Email), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) , new Claim("firstname", user.Firstname), new Claim("lastname", user.Lastname) }; - foreach (var role in roles) - { - claims.Add(new Claim(ClaimTypes.Role, role)); - } + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"])); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"])); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var token = new JwtSecurityToken( - issuer: configuration["Jwt:Issuer"], - audience: configuration["Jwt:Audience"], - claims: claims, - expires: DateTime.Now.AddMinutes(15), - signingCredentials: credentials - ); - return new JwtSecurityTokenHandler().WriteToken(token); - } + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], + claims: claims, + expires: DateTime.Now.AddMinutes(15), + signingCredentials: credentials + ); + return new JwtSecurityTokenHandler().WriteToken(token); } + } } diff --git a/Entities/BlogPost.cs b/Entities/BlogPost.cs index 145b325..5409bce 100644 --- a/Entities/BlogPost.cs +++ b/Entities/BlogPost.cs @@ -1,15 +1,16 @@ namespace Entities { - public class BlogPost:BaseEntity - { - public string Title { get; set; } - public string Content { get; set; } - public Guid ApplicationUserId { get; set; } - public ApplicationUser ApplicationUser { get; set; } + public class BlogPost : BaseEntity + { + public string Title { get; set; } + public string Content { get; set; } + public Guid ApplicationUserId { get; set; } + public ApplicationUser ApplicationUser { get; set; } - public List Comments { get; set; } = new List(); + public List Comments { get; set; } = new List(); - public string? ImageUrl { get; set; } + // TODO: some URL validation? I know there's something built-in already provided. + public string? ImageUrl { get; set; } - } + } } diff --git a/README.md b/README.md index ad8078b..6204465 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This is a RESTful API built with **ASP.NET Core** that supports: - ⚡ Filtering, Sorting, and Pagination - 🛡️ Secure API Endpoints with ASP.NET Identity ---- + ## 🚀 Tech Stack @@ -28,13 +28,19 @@ This is a RESTful API built with **ASP.NET Core** that supports: ## 📦 Features + + ### 🔐 Authentication & Authorization + - Register with roles - Login and receive JWT - Secure endpoints with role-based access (`[Authorize(Roles = "...")]`) - Identity password configuration customized for simplicity ### 📰 Blog Management + - **Create** a blog post with optional image upload - **Get** all posts with: - Filtering (`filterOn`, `filterQuery`) @@ -47,6 +53,8 @@ This is a RESTful API built with **ASP.NET Core** that supports: ### 🛠 Sample Endpoints + + | Endpoint | Method | Auth | Description | |----------------------------------------|--------|--------------|----------------------------------| | `/api/auth/register` | POST | ❌ | Register a new user |