Yakuniy loyiha — To-Do API: CRUD + JWT + EF Core + Docker
Maqsad
Kurs davomida o'rgangan hamma narsani bitta to'liq loyihada birlashtiramiz:
- Ro'yxatdan o'tish va login — BCrypt + JWT (58–60)
- Har bir foydalanuvchi faqat o'z vazifalarini ko'radi (61)
- CRUD — EF Core + PostgreSQL (49–53)
- DTO, validatsiya, ProblemDetails (45–47, 65)
- Filtrlash va sahifalash (71)
- OpenAPI hujjat (72), health check (74)
- Testlar (76–78)
- Docker + docker-compose (80–81)
Hayotiy o'xshatma
Haydovchilik guvohnomasi imtihoni. Siz alohida darslarda rul burashni, tormozlashni, belgilarni, parkovkani o'rgandingiz. Imtihonda esa hammasi birga kerak — bitta marshrutda, real yo'lda. Yakuniy loyiha — sizning imtihon marshrutingiz.
Loyiha tuzilmasi
dotnet new sln -n Todo
dotnet new webapi -n Todo.Api
dotnet new xunit -n Todo.Tests
dotnet sln add Todo.Api Todo.Tests
dotnet add Todo.Tests reference Todo.Api
cd Todo.Api
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package BCrypt.Net-Next
dotnet add package Scalar.AspNetCore
dotnet add package AspNetCore.HealthChecks.NpgSql
Todo.Api/
├── Data/TodoDbContext.cs
├── Entities/Foydalanuvchi.cs, Vazifa.cs
├── Dtos/AuthDtos.cs, VazifaDtos.cs
├── Services/TokenService.cs
├── Endpoints/AuthEndpointlari.cs, VazifaEndpointlari.cs
├── Program.cs
└── Dockerfile
1. Entity lar va DbContext
public class Foydalanuvchi
{
public int Id { get; set; }
public string Email { get; set; } = "";
public string ParolXesh { get; set; } = "";
public List<Vazifa> Vazifalar { get; set; } = new();
}
public enum Muhimlik { Past, Orta, Yuqori }
public class Vazifa
{
public int Id { get; set; }
public string Sarlavha { get; set; } = "";
public string? Tavsif { get; set; }
public bool Bajarilgan { get; set; }
public Muhimlik Muhimlik { get; set; } = Muhimlik.Orta;
public DateTime? Muddat { get; set; }
public DateTime Yaratilgan { get; set; } = DateTime.UtcNow;
public int FoydalanuvchiId { get; set; }
public Foydalanuvchi Foydalanuvchi { get; set; } = null!;
}
public class TodoDbContext(DbContextOptions<TodoDbContext> o) : DbContext(o)
{
public DbSet<Foydalanuvchi> Foydalanuvchilar => Set<Foydalanuvchi>();
public DbSet<Vazifa> Vazifalar => Set<Vazifa>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Foydalanuvchi>(e =>
{
e.HasIndex(f => f.Email).IsUnique();
e.Property(f => f.Email).HasMaxLength(200);
e.Property(f => f.ParolXesh).HasMaxLength(100);
});
mb.Entity<Vazifa>(e =>
{
e.Property(v => v.Sarlavha).HasMaxLength(200);
e.Property(v => v.Muhimlik).HasConversion<string>().HasMaxLength(20);
e.HasOne(v => v.Foydalanuvchi).WithMany(f => f.Vazifalar)
.HasForeignKey(v => v.FoydalanuvchiId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(v => new { v.FoydalanuvchiId, v.Bajarilgan });
});
}
}
2. DTO lar
public record RoyxatDto(string Email, string Parol);
public record LoginDto(string Email, string Parol);
public record TokenDto(string AccessToken, int AmalQilishSoniya);
public record VazifaYaratDto(string Sarlavha, string? Tavsif, Muhimlik Muhimlik = Muhimlik.Orta, DateTime? Muddat = null);
public record VazifaYangilaDto(string Sarlavha, string? Tavsif, bool Bajarilgan, Muhimlik Muhimlik, DateTime? Muddat);
public record VazifaDto(int Id, string Sarlavha, string? Tavsif, bool Bajarilgan, Muhimlik Muhimlik, DateTime? Muddat, DateTime Yaratilgan);
public record VazifaSorov(bool? Bajarilgan, Muhimlik? Muhimlik, string? Qidiruv, int Sahifa = 1, int Hajm = 20);
public record Sahifa<T>(List<T> Elementlar, int SahifaRaqami, int Hajm, int Jami);
public static class VazifaMapping
{
public static VazifaDto ToDto(this Vazifa v) =>
new(v.Id, v.Sarlavha, v.Tavsif, v.Bajarilgan, v.Muhimlik, v.Muddat, v.Yaratilgan);
}
3. Token servisi
public class JwtSozlamalari
{
public string Issuer { get; set; } = "";
public string Audience { get; set; } = "";
public string Kalit { get; set; } = "";
public int Daqiqa { get; set; } = 60;
}
public class TokenService(IOptions<JwtSozlamalari> opt)
{
private readonly JwtSozlamalari _s = opt.Value;
public TokenDto Yarat(Foydalanuvchi f)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, f.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, f.Email)
};
var imzo = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_s.Kalit)), SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(_s.Issuer, _s.Audience, claims,
expires: DateTime.UtcNow.AddMinutes(_s.Daqiqa), signingCredentials: imzo);
return new TokenDto(new JwtSecurityTokenHandler().WriteToken(token), _s.Daqiqa * 60);
}
}
4. Auth endpoint lari
public static class AuthEndpointlari
{
public static void MapAuth(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/auth").WithTags("Auth").AllowAnonymous();
g.MapPost("/royxat", async Task<Results<Created, ValidationProblem, Conflict<string>>> (
RoyxatDto dto, TodoDbContext db, CancellationToken ct) =>
{
var xatolar = new Dictionary<string, string[]>();
if (!dto.Email.Contains('@')) xatolar["email"] = ["Email noto'g'ri"];
if (dto.Parol.Length < 8) xatolar["parol"] = ["Parol kamida 8 belgi"];
if (xatolar.Count > 0) return TypedResults.ValidationProblem(xatolar);
var email = dto.Email.Trim().ToLowerInvariant();
if (await db.Foydalanuvchilar.AnyAsync(f => f.Email == email, ct))
return TypedResults.Conflict("Bu email allaqachon ro'yxatdan o'tgan");
db.Foydalanuvchilar.Add(new Foydalanuvchi
{
Email = email,
ParolXesh = BCrypt.Net.BCrypt.HashPassword(dto.Parol, 12)
});
await db.SaveChangesAsync(ct);
return TypedResults.Created();
});
g.MapPost("/login", async Task<Results<Ok<TokenDto>, UnauthorizedHttpResult>> (
LoginDto dto, TodoDbContext db, TokenService tokens, CancellationToken ct) =>
{
var email = dto.Email.Trim().ToLowerInvariant();
var f = await db.Foydalanuvchilar.FirstOrDefaultAsync(x => x.Email == email, ct);
if (f is null || !BCrypt.Net.BCrypt.Verify(dto.Parol, f.ParolXesh))
return TypedResults.Unauthorized();
return TypedResults.Ok(tokens.Yarat(f));
}).RequireRateLimiting("login");
}
}
5. Vazifa endpoint lari
public static class VazifaEndpointlari
{
private static int UserId(ClaimsPrincipal u) =>
int.Parse(u.FindFirstValue(ClaimTypes.NameIdentifier)!);
public static void MapVazifalar(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/vazifalar").WithTags("Vazifalar").RequireAuthorization();
g.MapGet("/", async ([AsParameters] VazifaSorov s, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
var q = db.Vazifalar.AsNoTracking().Where(v => v.FoydalanuvchiId == UserId(user));
if (s.Bajarilgan is not null) q = q.Where(v => v.Bajarilgan == s.Bajarilgan);
if (s.Muhimlik is not null) q = q.Where(v => v.Muhimlik == s.Muhimlik);
if (!string.IsNullOrWhiteSpace(s.Qidiruv))
q = q.Where(v => EF.Functions.ILike(v.Sarlavha, quot;%{s.Qidiruv}%"));
var hajm = Math.Clamp(s.Hajm, 1, 100);
var sahifa = Math.Max(s.Sahifa, 1);
var jami = await q.CountAsync(ct);
var elementlar = await q
.OrderBy(v => v.Bajarilgan).ThenBy(v => v.Muddat ?? DateTime.MaxValue).ThenByDescending(v => v.Id)
.Skip((sahifa - 1) * hajm).Take(hajm)
.Select(v => new VazifaDto(v.Id, v.Sarlavha, v.Tavsif, v.Bajarilgan, v.Muhimlik, v.Muddat, v.Yaratilgan))
.ToListAsync(ct);
return TypedResults.Ok(new Sahifa<VazifaDto>(elementlar, sahifa, hajm, jami));
});
g.MapGet("/{id:int}", async Task<Results<Ok<VazifaDto>, NotFound>> (
int id, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
var v = await db.Vazifalar.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.FoydalanuvchiId == UserId(user), ct);
return v is null ? TypedResults.NotFound() : TypedResults.Ok(v.ToDto());
}).WithName("VazifaOl");
g.MapPost("/", async Task<Results<CreatedAtRoute<VazifaDto>, ValidationProblem>> (
VazifaYaratDto dto, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(dto.Sarlavha) || dto.Sarlavha.Length > 200)
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{ ["sarlavha"] = ["Sarlavha 1–200 belgi bo'lishi kerak"] });
var v = new Vazifa
{
Sarlavha = dto.Sarlavha.Trim(),
Tavsif = dto.Tavsif,
Muhimlik = dto.Muhimlik,
Muddat = dto.Muddat,
FoydalanuvchiId = UserId(user)
};
db.Vazifalar.Add(v);
await db.SaveChangesAsync(ct);
return TypedResults.CreatedAtRoute(v.ToDto(), "VazifaOl", new { id = v.Id });
});
g.MapPut("/{id:int}", async Task<Results<NoContent, NotFound>> (
int id, VazifaYangilaDto dto, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
var v = await db.Vazifalar.FirstOrDefaultAsync(x => x.Id == id && x.FoydalanuvchiId == UserId(user), ct);
if (v is null) return TypedResults.NotFound();
v.Sarlavha = dto.Sarlavha.Trim();
v.Tavsif = dto.Tavsif;
v.Bajarilgan = dto.Bajarilgan;
v.Muhimlik = dto.Muhimlik;
v.Muddat = dto.Muddat;
await db.SaveChangesAsync(ct);
return TypedResults.NoContent();
});
g.MapPatch("/{id:int}/bajarildi", async Task<Results<NoContent, NotFound>> (
int id, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
var soni = await db.Vazifalar
.Where(x => x.Id == id && x.FoydalanuvchiId == UserId(user))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Bajarilgan, true), ct);
return soni == 0 ? TypedResults.NotFound() : TypedResults.NoContent();
});
g.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (
int id, ClaimsPrincipal user, TodoDbContext db, CancellationToken ct) =>
{
var soni = await db.Vazifalar
.Where(x => x.Id == id && x.FoydalanuvchiId == UserId(user))
.ExecuteDeleteAsync(ct);
return soni == 0 ? TypedResults.NotFound() : TypedResults.NoContent();
});
}
}
Har bir so'rovda FoydalanuvchiId == UserId(user) — boshqaning vazifasini ko'rish, o'zgartirish yoki o'chirish imkonsiz (IDOR himoyasi, 61-dars). Begona vazifa uchun — 404, mavjudligi ham oshkor qilinmaydi.
[!TIP]
UserId(user)LINQ ifodasi ichida chaqirilgan — EF Core uni bazaga yuborishdan oldin qiymat sifatida hisoblaydi (closure). Aniqroq bo'lishi uchun uni avval o'zgaruvchiga olish ham mumkin:var uid = UserId(user);.
6. Program.cs
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Baza
builder.Services.AddDbContext<TodoDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Todo")));
// JWT
builder.Services.AddOptions<JwtSozlamalari>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(s => s.Kalit.Length >= 32, "Jwt:Kalit kamida 32 belgi")
.ValidateOnStart();
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtSozlamalari>()!;
builder.Services.AddSingleton<TokenService>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Kalit)),
ClockSkew = TimeSpan.FromSeconds(30)
});
builder.Services.AddAuthorization();
// Sifat
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Todo")!, tags: ["ready"]);
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = 429;
o.AddPolicy("login", ctx => RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "?",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1) }));
});
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuth();
app.MapVazifalar();
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
// Lokal va demo uchun — production da migratsiya alohida qadam (81-dars)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<TodoDbContext>().Database.MigrateAsync();
}
app.Run();
public partial class Program { }
// appsettings.json — maxfiy qiymatlarsiz
{
"Jwt": { "Issuer": "todo-api", "Audience": "todo-app", "Daqiqa": 60 },
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }
}
dotnet user-secrets set "Jwt:Kalit" "lokal-ishlab-chiqish-uchun-uzun-tasodifiy-kalit-123"
dotnet user-secrets set "ConnectionStrings:Todo" "Host=localhost;Database=todo;Username=todo;Password=todo"
dotnet ef migrations add Boshlangich
7. Integration test
public class VazifaApiTests(TodoApiFactory f) : IClassFixture<TodoApiFactory>
{
private async Task<HttpClient> KirganKlientAsync(string email)
{
var c = f.CreateClient();
await c.PostAsJsonAsync("/api/auth/royxat", new { email, parol = "Kuchli!Parol1" });
var login = await c.PostAsJsonAsync("/api/auth/login", new { email, parol = "Kuchli!Parol1" });
var token = await login.Content.ReadFromJsonAsync<TokenDto>();
c.DefaultRequestHeaders.Authorization = new("Bearer", token!.AccessToken);
return c;
}
[Fact]
public async Task Vazifa_yaratish_va_royxatda_korinish()
{
var c = await KirganKlientAsync(quot;{Guid.NewGuid()}@test.uz");
var yarat = await c.PostAsJsonAsync("/api/vazifalar", new { sarlavha = "Kursni tugatish" });
Assert.Equal(HttpStatusCode.Created, yarat.StatusCode);
var royxat = await c.GetFromJsonAsync<Sahifa<VazifaDto>>("/api/vazifalar");
Assert.Contains(royxat!.Elementlar, v => v.Sarlavha == "Kursni tugatish");
}
[Fact]
public async Task Boshqa_foydalanuvchi_vazifasi_404()
{
var lola = await KirganKlientAsync(quot;{Guid.NewGuid()}@test.uz");
var anvar = await KirganKlientAsync(quot;{Guid.NewGuid()}@test.uz");
var yarat = await lola.PostAsJsonAsync("/api/vazifalar", new { sarlavha = "Shaxsiy" });
var v = await yarat.Content.ReadFromJsonAsync<VazifaDto>();
var javob = await anvar.GetAsync(quot;/api/vazifalar/{v!.Id}");
Assert.Equal(HttpStatusCode.NotFound, javob.StatusCode);
}
[Fact]
public async Task Tokensiz_401()
{
var javob = await f.CreateClient().GetAsync("/api/vazifalar");
Assert.Equal(HttpStatusCode.Unauthorized, javob.StatusCode);
}
}
TodoApiFactory — 78-darsdagi Testcontainers PostgreSQL fixture i, test uchun Jwt:Kalit va connection string bilan.
8. Docker
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["Todo.Api/Todo.Api.csproj", "Todo.Api/"]
RUN dotnet restore "Todo.Api/Todo.Api.csproj"
COPY . .
RUN dotnet publish "Todo.Api/Todo.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Todo.Api.dll"]
# compose.yaml
services:
api:
build:
context: .
dockerfile: Todo.Api/Dockerfile
ports:
- "5000:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Todo: "Host=db;Database=todo;Username=todo;Password=${DB_PAROL}"
Jwt__Kalit: ${JWT_KALIT}
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_DB: todo
POSTGRES_USER: todo
POSTGRES_PASSWORD: ${DB_PAROL}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U todo -d todo"]
interval: 5s
retries: 10
volumes:
pgdata:
# .env (git ga qo'shilmaydi)
DB_PAROL=todo-lokal-parol
JWT_KALIT=docker-uchun-kamida-32-belgili-tasodifiy-kalit-qiymati
docker compose up -d --build
9. Sinab ko'rish
curl -X POST http://localhost:5000/api/auth/royxat \
-H "Content-Type: application/json" \
-d '{"email":"lola@mail.uz","parol":"Kuchli!Parol1"}'
TOKEN=$(curl -s -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"lola@mail.uz","parol":"Kuchli!Parol1"}' | jq -r .accessToken)
curl -X POST http://localhost:5000/api/vazifalar \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"sarlavha":".NET kursini tugatish","muhimlik":"Yuqori"}'
curl "http://localhost:5000/api/vazifalar?bajarilgan=false&sahifa=1" -H "Authorization: Bearer $TOKEN"
curl http://localhost:5000/health/ready
Yoki brauzerda: http://localhost:5000/scalar/v1.
Mustaqil kengaytirish uchun g'oyalar
- Refresh token va logout (62-dars)
- Kategoriyalar / teglar — many-to-many (55-dars)
- Rollar — admin barcha foydalanuvchilar statistikasini ko'radi (63-dars)
- Cache — statistika endpoint i uchun
HybridCache(67–68) - Serilog + Seq (66-dars)
- API versiyalash —
/api/v1/(73-dars) - CI — GitHub Actions da
dotnet testva Docker image build
Xulosa
Yakuniy loyiha kursning barcha qismlarini birlashtirdi: EF Core + PostgreSQL bilan model va migratsiyalar, BCrypt bilan parollar, JWT autentifikatsiya, foydalanuvchi bo'yicha ma'lumotlarni ajratish (IDOR himoyasi), DTO va validatsiya, ProblemDetails, filtrlash va sahifalash, rate limit, OpenAPI, health check lar, integration testlar va Docker.
Bu — haqiqiy backend xizmatining asosi: keyingi har bir xususiyat shu tuzilmaga qo'shiladi.
Kurs yakunlandi
Tabriklaymiz — .NET Backend kursini tugatdingiz! Siz C# tilining asoslaridan boshlab OOP, LINQ va async/await, ASP.NET Core, Web API, Entity Framework Core, autentifikatsiya va avtorizatsiya, cache, logging, test va Docker gacha bo'lgan yo'lni bosib o'tdingiz.
Keyingi qadamlar:
- O'z loyihangizni yarating va GitHub ga joylang: do'kon API, bron qilish tizimi, Telegram bot backend i
- Arxitektura — Clean Architecture, CQRS, MediatR, Domain-Driven Design
- Real-time — SignalR bilan chat va bildirishnomalar
- Xabar navbatlari — RabbitMQ, MassTransit, fon vazifalari (
BackgroundService) - Bulut — Azure yoki boshqa platformaga deploy, CI/CD
- .NET Aspire — taqsimlangan ilovalarni qurish va kuzatish