diff --git a/blogging_api/Controllers/BlogsController.cs b/blogging_api/Controllers/BlogsController.cs new file mode 100644 index 0000000..be91392 --- /dev/null +++ b/blogging_api/Controllers/BlogsController.cs @@ -0,0 +1,80 @@ +using blogging_api.Dtos; +using blogging_api.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.ResponseCaching; + +namespace blogging_api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BlogsController : ControllerBase +{ + private readonly BlogService _service; + + public BlogsController(BlogService service) + { + _service = service; + } + + [HttpPost] + public async Task> CreateBlog([FromBody] CreateBlogRequest request) + { + var createdBlog = await _service.CreateBlogAsync(request); + + if (createdBlog == null) + { + return NotFound(); + } + + return createdBlog; + } + + [HttpGet] + public async Task> GetBlogs([FromQuery] BlogQueryParams query) + { + var blogs = await _service.GetBlogsAsync(query); + return Ok(blogs); + } + + [HttpGet("{id:int}")] + public async Task> GetBlogById(int id) + { + var response = await _service.GetBlogById(id); + + if (response == null) + { + return NotFound(); + } + + return response; + } + + [HttpPut("{id:int}")] + public async Task> UpdateBlog( + [FromRoute] int id, + [FromBody] UpdateBlogRequest request + ) + { + var result = await _service.UpdateBlogAsync(id, request); + + if (result is not null) + { + return result; + } + + return NotFound(); + } + + [HttpDelete("{id:int}")] + public async Task DeleteBlogById([FromRoute] int id) + { + var success = await _service.DeleteBlogByIdAsync(id); + + if (success) + { + return Ok(); + } + + return Problem(); + } +} \ No newline at end of file diff --git a/blogging_api/Data/BlogDbContext.cs b/blogging_api/Data/BlogDbContext.cs new file mode 100644 index 0000000..46ad59d --- /dev/null +++ b/blogging_api/Data/BlogDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using blogging_api.Models; + +namespace blogging_api.Data; + +public class BlogDbContext : DbContext +{ + public BlogDbContext(DbContextOptions options): base(options) + { + + } + + public DbSet BlogPosts => Set(); + public DbSet BlogTags => Set(); +} \ No newline at end of file diff --git a/blogging_api/Dtos/Request.cs b/blogging_api/Dtos/Request.cs new file mode 100644 index 0000000..ffc5d9c --- /dev/null +++ b/blogging_api/Dtos/Request.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using blogging_api.Models; + +namespace blogging_api.Dtos; + +public record CreateBlogRequest( + [Required(ErrorMessage = "Title is required")] + string Title, + string Content, + List Tags +); + +public record UpdateBlogRequest( + string? Title, + string? Content, + List? Tags +); + +public record BlogQueryParams( + List? Terms = null, + List? Tags = null +); \ No newline at end of file diff --git a/blogging_api/Dtos/Response.cs b/blogging_api/Dtos/Response.cs new file mode 100644 index 0000000..42e7dea --- /dev/null +++ b/blogging_api/Dtos/Response.cs @@ -0,0 +1,10 @@ +namespace blogging_api.Dtos; + +public record BlogResponse( + int Id, + string Title, + string Content, + List? Tags, + DateTimeOffset CreatedAt, + DateTimeOffset? EditedAt +); \ No newline at end of file diff --git a/blogging_api/Migrations/20260908225407_InitialCreate.Designer.cs b/blogging_api/Migrations/20260908225407_InitialCreate.Designer.cs new file mode 100644 index 0000000..e79659d --- /dev/null +++ b/blogging_api/Migrations/20260908225407_InitialCreate.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using blogging_api.Data; + +#nullable disable + +namespace blogging_api.Migrations +{ + [DbContext(typeof(BlogDbContext))] + [Migration("20260908225407_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("BlogBlogTag", b => + { + b.Property("BlogsId") + .HasColumnType("int"); + + b.Property("TagsId") + .HasColumnType("int"); + + b.HasKey("BlogsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BlogBlogTag"); + }); + + modelBuilder.Entity("blogging_api.Models.Blog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EditedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("blogging_api.Models.BlogTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Tag") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BlogTags"); + }); + + modelBuilder.Entity("BlogBlogTag", b => + { + b.HasOne("blogging_api.Models.Blog", null) + .WithMany() + .HasForeignKey("BlogsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("blogging_api.Models.BlogTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/blogging_api/Migrations/20260908225407_InitialCreate.cs b/blogging_api/Migrations/20260908225407_InitialCreate.cs new file mode 100644 index 0000000..90b9c23 --- /dev/null +++ b/blogging_api/Migrations/20260908225407_InitialCreate.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace blogging_api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BlogPosts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(max)", nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + EditedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogPosts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BlogTags", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Tag = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogTags", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BlogBlogTag", + columns: table => new + { + BlogsId = table.Column(type: "int", nullable: false), + TagsId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BlogBlogTag", x => new { x.BlogsId, x.TagsId }); + table.ForeignKey( + name: "FK_BlogBlogTag_BlogPosts_BlogsId", + column: x => x.BlogsId, + principalTable: "BlogPosts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BlogBlogTag_BlogTags_TagsId", + column: x => x.TagsId, + principalTable: "BlogTags", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BlogBlogTag_TagsId", + table: "BlogBlogTag", + column: "TagsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BlogBlogTag"); + + migrationBuilder.DropTable( + name: "BlogPosts"); + + migrationBuilder.DropTable( + name: "BlogTags"); + } + } +} diff --git a/blogging_api/Migrations/BlogDbContextModelSnapshot.cs b/blogging_api/Migrations/BlogDbContextModelSnapshot.cs new file mode 100644 index 0000000..2555bdb --- /dev/null +++ b/blogging_api/Migrations/BlogDbContextModelSnapshot.cs @@ -0,0 +1,101 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using blogging_api.Data; + +#nullable disable + +namespace blogging_api.Migrations +{ + [DbContext(typeof(BlogDbContext))] + partial class BlogDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("BlogBlogTag", b => + { + b.Property("BlogsId") + .HasColumnType("int"); + + b.Property("TagsId") + .HasColumnType("int"); + + b.HasKey("BlogsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BlogBlogTag"); + }); + + modelBuilder.Entity("blogging_api.Models.Blog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EditedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("blogging_api.Models.BlogTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Tag") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BlogTags"); + }); + + modelBuilder.Entity("BlogBlogTag", b => + { + b.HasOne("blogging_api.Models.Blog", null) + .WithMany() + .HasForeignKey("BlogsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("blogging_api.Models.BlogTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/blogging_api/Models/Blog.cs b/blogging_api/Models/Blog.cs new file mode 100644 index 0000000..6d49c3c --- /dev/null +++ b/blogging_api/Models/Blog.cs @@ -0,0 +1,11 @@ +namespace blogging_api.Models; + +public class Blog +{ + public int Id { get; set; } + public string Title { get; set; }= string.Empty; + public string Content { get; set; } = string.Empty; + public List Tags { get; set; } = new(); + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? EditedAt { get; set; } +} \ No newline at end of file diff --git a/blogging_api/Models/BlogTag.cs b/blogging_api/Models/BlogTag.cs new file mode 100644 index 0000000..a299142 --- /dev/null +++ b/blogging_api/Models/BlogTag.cs @@ -0,0 +1,8 @@ +namespace blogging_api.Models; + +public class BlogTag +{ + public int Id { get; set; } + public string Tag { get; set;} = string.Empty; + public List Blogs { get; set; } = new(); +} \ No newline at end of file diff --git a/blogging_api/Program.cs b/blogging_api/Program.cs new file mode 100644 index 0000000..fe65845 --- /dev/null +++ b/blogging_api/Program.cs @@ -0,0 +1,35 @@ +using blogging_api.Data; +using blogging_api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, sqlOptions => + { + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(10), + errorNumbersToAdd: null + ); + }) +); + +builder.Services.AddScoped(); +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | + Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto +}); + +app.MapGet("/", () => "here"); +app.MapOpenApi(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/blogging_api/Properties/launchSettings.json b/blogging_api/Properties/launchSettings.json new file mode 100644 index 0000000..3615ede --- /dev/null +++ b/blogging_api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/blogging_api/README.md b/blogging_api/README.md new file mode 100644 index 0000000..b9f2abc --- /dev/null +++ b/blogging_api/README.md @@ -0,0 +1,52 @@ +# Personal Blogging Platform API + +A RESTful backend API built with ASP.NET Core and Entity Framework Core. Provides full CRUD functionality for managing blog posts, supports many-to-many tag relationships, and includes filtering across titles, contents, and tags. + +This is **Project 1** of the [20 Backend Project Ideas Roadmap](https://roadmap.sh/backend/project-ideas) + +--- + +## Tech Stack + +- **Framework**: ASP.NET Core (.NET 10+) +- **ORM**: Entity Framework Core +- **Database**: Azure SQL Server +- **Architecture**: Controller-Service pattern using typed DTO records + +--- +## Endpoints + +| Method | Endpoint | Query / Body | Description | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/blogs` | `?term=...&tag=...` | List filtered posts | +| `GET` | `/api/blogs/{id}` | — | Get post by ID | +| `POST` | `/api/blogs` | `{ title, content, tags: [] }` | Create a post | +| `PUT` | `/api/blogs/{id}` | `{ title, content, tags: [] }` | Update a post | +| `DELETE`| `/api/blogs/{id}` | — | Delete a post | + +--- + +## Quick Example + +```json +// POST /api/blogs +{ + "title": "Clean Architecture in .NET", + "content": "Exploring repository patterns and EF Core optimizations.", + "tags": ["dotnet", "csharp", "backend"] +} +``` +--- + +## Getting Started + +```bash +git clone https://github.com/p-ragudo/backend-projects.git +cd backend-projects/blogging_api + +# Update ConnectionStrings:DefaultConnection in appsettings.json +dotnet ef database update +dotnet run +``` + +Access the OpenAPI document at `http://localhost:8080/openapi/v1.json`. \ No newline at end of file diff --git a/blogging_api/Services/BlogService.cs b/blogging_api/Services/BlogService.cs new file mode 100644 index 0000000..97bbbb3 --- /dev/null +++ b/blogging_api/Services/BlogService.cs @@ -0,0 +1,211 @@ +using blogging_api.Data; +using blogging_api.Dtos; +using blogging_api.Models; +using Microsoft.EntityFrameworkCore; + +namespace blogging_api.Services; + +public class BlogService +{ + private readonly BlogDbContext _context; + public BlogService(BlogDbContext context) + { + _context = context; + } + + public async Task CreateBlogAsync(CreateBlogRequest dto) + { + TimeZoneInfo targetZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila"); + DateTimeOffset phDateTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, targetZone); + + var incomingTagNames = dto.Tags + .Select(t => t.Trim()) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var existingTags = await _context.BlogTags + .Where(t => incomingTagNames.Contains(t.Tag)) + .ToListAsync(); + + var existingTagNames = existingTags + .Select(t => t.Tag.ToLowerInvariant()) + .ToHashSet(); + + var newTags = incomingTagNames + .Where(name => !existingTagNames.Contains(name.ToLowerInvariant())) + .Select(name => new BlogTag { Tag = name}) + .ToList(); + + var allTags = existingTags.Concat(newTags).ToList(); + + var blog = new Blog + { + Title = dto.Title, + Content = dto.Content, + Tags = allTags, + CreatedAt = phDateTime + }; + + _context.BlogPosts.Add(blog); + await _context.SaveChangesAsync(); + + return new BlogResponse( + blog.Id, + blog.Title, + blog.Content, + blog.Tags.Select(t => t.Tag).ToList(), + blog.CreatedAt, + blog.EditedAt + ); + } + + public async Task?> GetBlogsAsync(BlogQueryParams query) + { + if (query.Terms == null && query.Tags == null) + { + var blogs = await _context.BlogPosts + .AsNoTracking() + .Select(b => new BlogResponse( + b.Id, + b.Title, + b.Content, + b.Tags.Select(t => t.Tag).ToList(), + b.CreatedAt, + b.EditedAt + )) + .ToListAsync(); + + return blogs; + } + + var blogsQuery = _context.BlogPosts + .AsNoTracking() + .AsQueryable(); + + if (query.Terms is { Count: > 0}) + { + var terms = query.Terms + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim().ToLower()) + .Distinct() + .ToList(); + + if (terms.Count > 0) + { + blogsQuery = blogsQuery.Where(b => + terms.Any(term => + b.Title.ToLower().Trim().Contains(term) || + b.Content.ToLower().Trim().Contains(term))); + } + } + + if (query.Tags is { Count: > 0}) + { + var targetTags = query.Tags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim().ToLower()) + .Distinct() + .ToList(); + + if (targetTags.Count > 0) + { + blogsQuery = blogsQuery.Where(b => + b.Tags.Any(t => targetTags.Contains(t.Tag.Trim().ToLower()))); + } + } + + var results = await blogsQuery + .OrderByDescending(b => b.CreatedAt) + .Select(b => new BlogResponse( + b.Id, + b.Title, + b.Content, + b.Tags.Select(t => t.Tag).ToList(), + b.CreatedAt, + b.EditedAt + )) + .ToListAsync(); + + return results; + } + + public async Task GetBlogById(int id) + { + var blog = await _context.BlogPosts + .Include(b => b.Tags) + .FirstOrDefaultAsync(b => b.Id == id); + + if (blog == null) + { + return null; + } + + var response = new BlogResponse( + blog.Id, + blog.Title, + blog.Content, + blog.Tags.Select(b => b.Tag).ToList(), + blog.CreatedAt, + blog.EditedAt + ); + + return response; + } + + public async Task UpdateBlogAsync(int id, UpdateBlogRequest dto) + { + var blog = await _context.BlogPosts + .Include(b => b.Tags) + .FirstOrDefaultAsync(t => t.Id == id); + + if (blog == null) + { + return null; + } + + blog.Title = dto.Title ?? blog.Title; + blog.Content = dto.Content ?? blog.Content; + + if (dto.Tags is not null) + { + var existingTags = await _context.BlogTags + .Where(t => dto.Tags.Contains(t.Tag)) + .ToListAsync(); + + blog.Tags = dto.Tags.Select(tagName => + existingTags.FirstOrDefault(t => t.Tag == tagName) + ?? new BlogTag{ Tag = tagName} + ).ToList(); + } + + TimeZoneInfo targetZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila"); + DateTimeOffset phDateTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, targetZone); + blog.EditedAt = phDateTime; + + await _context.SaveChangesAsync(); + + var blogResponse = new BlogResponse + ( + blog.Id, + blog.Title, + blog.Content, + blog.Tags.Select(t => t.Tag).ToList(), + blog.CreatedAt, + blog.EditedAt + ); + + return blogResponse; + } + + public async Task DeleteBlogByIdAsync(int id) + { + var blog = await _context.BlogPosts.FirstOrDefaultAsync(t => t.Id == id); + if (blog is null) return false; + + _context.BlogPosts.Remove(blog); + await _context.SaveChangesAsync(); + + return true; + } +} \ No newline at end of file diff --git a/blogging_api/appsettings.json b/blogging_api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/blogging_api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/blogging_api/blogging_api.csproj b/blogging_api/blogging_api.csproj new file mode 100644 index 0000000..d5f070c --- /dev/null +++ b/blogging_api/blogging_api.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + \ No newline at end of file diff --git a/blogging_api/blogging_api.http b/blogging_api/blogging_api.http new file mode 100644 index 0000000..51e8693 --- /dev/null +++ b/blogging_api/blogging_api.http @@ -0,0 +1,6 @@ +@blogging_api_HostAddress = http://localhost:5288 + +GET {{blogging_api_HostAddress}}/weatherforecast/ +Accept: application/json + +###