Object Mapping (نگاشت شیء) و عملیات CRUD با وضعیتهای HTTP
Object Mapping (نگاشت شیء) و عملیات CRUD با وضعیتهای HTTP
منبع: Coding Clean, Reliable, and Safe REST APIs with ASP.NET Core 8 — Anthony Giretti
اعتبار ترجمه: ترجمه با کمک هوش مصنوعی
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Object Mapping (نگاشت شیء) و عملیات CRUD با HTTP Statusهای صحیح
شکل ۴-۲۲. Validation موفق Object از نوع Country در POST /countriesFluentValidation Library قدرتمندی برای انواع Validation است و مثالهای کتاب فقط بخش سادهای از قابلیتهای آن را نشان میدهند. مهمترین اصل این بخش این است که هرگز نباید User Input را قابل اعتماد فرض کرد. Validation همهٔ ورودیها هم Best Practice است و هم بخشی از دفاع در برابر حملههای مخرب.
Object Mapping
پس از Validation باید Data ورودی را وارد Layerهای دیگر Application کنیم. در فصل ۳ اصول Separation of Concerns (SoC) و Abstraction را دیدیم. Input Parameterهای Endpoint مخصوص Web/API Layer هستند و نباید مستقیماً در Domain یا Infrastructure مصرف شوند.
برای انتقال اطلاعات از API Endpoint به Repository و Database، Objectهای ورودی را به Data Transfer Object (DTO) یا Domain Object Map میکنیم. DTO بخشی از Domain است و میتواند توسط Layerهای مختلف استفاده شود. مراحل مثال:
- ساخت Domain Layer و تعریف DTO در آن.
- ساخت Interface برای Abstract کردن Mapping بین API Input و DTO، همراه Implementation آن.
Interface و Implementation مربوط به Mapping در API Layer قرار میگیرند، زیرا Input Model متعلق به همین Layer است. Domain Layer نباید به API Layer وابسته شود؛ API Layer به Domain وابسته است.
شکل ۴-۲۳. مسئولیتهای API Layer و Domain Layer
ابتدا Class مربوط به CountryDto را در Domain Layer میسازیم.
Listing 4-17. کلاس CountryDto
namespace Domain.DTOs;
public class CountryDto
{
public string Name { get; set; }
public string Description { get; set; }
public string FlagUri { get; set; }
}
Signature این Class فعلاً با Country در API Layer یکسان است، اما این به معنی یکیکردن آنها نیست. Responsibility آنها متفاوت است و ممکن است مستقل از هم تغییر کنند. این تفاوت برای SoC بسیار مهم است.
شکل ۴-۲۴. API Layer و Domain Layer با عناصر اختصاصی خود
در Domain، Folderای با نام DTOs ساخته میشود و CountryDto در آن قرار میگیرد. سپس API Layer به Domain Reference میدهد. اکنون Mapper را در API میسازیم.
Listing 4-18. Interface مربوط به ICountryMapper
using AspNetCore8MinimalApis.Models;
using Domain.DTOs;
namespace AspNetCore8MinimalApis.Mapping.Interfaces;
public interface ICountryMapper
{
public CountryDto? Map(Country country);
}
Method برابر Map یک Country میگیرد و CountryDto? برمیگرداند.
Listing 4-19. کلاس CountryMapper
using AspNetCore8MinimalApis.Mapping.Interfaces;
using AspNetCore8MinimalApis.Models;
using Domain.DTOs;
namespace AspNetCore8MinimalApis.Mapping;
public class CountryMapper : ICountryMapper
{
public CountryDto? Map(Country country)
{
return country is not null ? new CountryDto
{
Name = country.Name,
Description = country.Description,
FlagUri = country.FlagUri,
} : null;
}
}
هدف این مثال پیچیدگی Mapping نیست، بلکه نشان دادن Abstraction و SoC است. Manual Mapping در این مثال از نظر Performance ساده و سریع است، اما چون Operation از طریق Interface Abstract شده، میتوان Implementation را با Libraryهایی مانند AutoMapper یا Mapster عوض کرد بدون آنکه Contract تغییر کند.
Pair مربوط به ICountryMapper/CountryMapper در Dependency Injection بهصورت Scoped ثبت میشود، زیرا نگه داشتن Instance تا پایان عمر Application ضروری نیست.
Listing 4-20. ثبت Mapper در Dependency Injection
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICountryMapper, CountryMapper>();
var app = builder.Build();
Listing 4-21. POST /countries با Mapper Injectشده
app.MapPost("/countries", ([FromBody] Country country,
IValidator<Country> validator, ICountryMapper mapper) => {
var validationResult = validator.Validate(country);
if (validationResult.IsValid)
{
var countryDto = mapper.Map(country);
//Do some work here
return Results.Created();
}
return Results.ValidationProblem(validationResult.
ToDictionary());
});
شکل ۴-۲۵. اجرای POST /countries با ICountryMapper Injectشده
مدیریت CRUD و HTTP Statusها
اکنون میتوان Endpointهای متداول Create، Retrieve، Update و Delete را ساخت. در HTTP معمولاً این Mapping برقرار است:
- POST: Create
- GET: Retrieve یک Entity یا Collection
- PUT: جایگزینی Entity و در برخی حالتها ایجاد آن اگر وجود نداشته باشد؛ Update در CRUD
- PATCH: Update جزئی Entity
- DELETE: Delete Entity
Handling HTTP Statuses
Class ایستا Results Methodهای متعددی برای تولید HTTP Statusهای رایج در Minimal API فراهم میکند.
جدول ۴-۵. Methodهای Results و HTTP Status تولیدشده| Method | Status |
|---|
| Accepted | 202 |
| AcceptedAtRoute | 202 |
| BadRequest | 400 |
| Bytes | 200, 206, 416 |
| Challenge | 401 |
| Conflict | 409 |
| Content | 200 |
| Created | 201 |
| CreatedAtRoute | 201 |
| File | 200, 206, 416 |
| Json | 200 |
| Forbid | 403 |
| Redirect | 301, 302, 307, 308 |
| RedirectToRoute | 301, 302, 307, 308 |
| SignIn | 200 |
| SignOut | 200 |
| Stream | 200, 206, 416 |
| Text | 200 |
| Unauthorized | 401 |
| UnproccessableEntity | 422 |
| ValidationProblem | 400 + ValidationProblemDetails |
| StatusCode | هر Status با Integer ورودی |
| Empty | 200 |
اگر Method اختصاصی برای Status مورد نیاز وجود نداشته باشد، StatusCode هر عدد Status را میپذیرد.
ساخت Service برای CRUD
برای مدیریت CountryDto، Serviceای میان API و Infrastructure ایجاد میکنیم. با رعایت Abstraction و SoC، Interface با نام ICountryService در Domain و Implementation با نام CountryService در Business Logic Layer (BLL) قرار میگیرد. جزئیات Implementation در اینجا مهم نیست؛ تمرکز روی نحوهٔ مصرف Service توسط Endpointها است.
شکل ۴-۲۶. مسئولیتهای API، Domain و BLL
Listing 4-22. Interface مربوط به ICountryService
using Domain.DTOs;
namespace Domain.Services;
public interface ICountryService
{
CountryDto Retrieve(int id);
List<CountryDto> GetAll();
int CreateOrUpdate(CountryDto country);
bool UpdateDescription(int id, string description);
bool Delete(int id);
}
Service باید بهصورت Scoped ثبت شود:
builder.Services.AddScoped<ICountryService, CountryService>();
برای CRUD، Property شناسه به CountryDto اضافه میشود و Nullable است، زیرا پیش از Creation ممکن است ID نداشته باشد.
Listing 4-23. CountryDto با ID Nullable
namespace Domain.DTOs;
public class CountryDto
{
public int? Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string FlagUri { get; set; }
}
همین ID به Input Model مربوط به Country در API نیز اضافه میشود. Contract متدها:
Retrieve(id): CountryDto متناظر با ID را برمیگرداند.GetAll(): Collection همهٔ CountryDtoها.CreateOrUpdate(country): اگر ID برابر Null باشد Create و در غیر این صورت Update. مثال عمداً ساده فرض میکند ID موجود معتبر است.UpdateDescription(id, description): Update جزئی Description و بازگرداندن Boolean نتیجه.Delete(id): حذف بر اساس ID و بازگرداندن Boolean نتیجه.
ساخت Endpointهای CRUD
Validator و Mapper برای Ruleهای جدید Update شدهاند و ICountryService در Endpointها Inject میشود.
Listing 4-24. پیادهسازی Endpointهای CRUD
// Create
app.MapPost("/countries", (
[FromBody] Country country,
IValidator<Country> validator,
ICountryMapper mapper,
ICountryService countryService) => {
var validationResult = validator.Validate(country);
if (validationResult.IsValid)
{
var countryDto = mapper.Map(country);
return Results.CreatedAtRoute(
"countryById",
new {
Id = countryService.CreateOrUpdate(
countryDto
)
}
);
}
return Results.ValidationProblem(
validationResult.ToDictionary()
);
});
// Retrieve
app.MapGet("/countries/{id}", (
int id, ICountryMapper mapper,
ICountryService countryService) => {
var country = countryService.Retrieve(id);
if (country is null)
return Results.NotFound();
return Results.Ok(mapper.Map(country));
}).WithName("countryById");
// Retrieve
app.MapGet("/countries", (
ICountryMapper mapper,
ICountryService countryService) => {
var countries = countryService.GetAll();
return Results.Ok(mapper.Map(countries));
});
// Update
app.MapPut("/countries", (
[FromBody] Country country,
IValidator<Country> validator,
ICountryMapper mapper,
ICountryService countryService) => {
var validationResult = validator.Validate(country);
if (validationResult.IsValid)
{
if (country.Id is null)
return Results.CreatedAtRoute(
"countryById",
new {
Id = countryService.CreateOrUpdate(
mapper.Map(country)
)
});
return Results.NoContent();
}
return Results.ValidationProblem(
validationResult.ToDictionary()
);
});
// Update
app.MapPatch("/countries/{id}", (
int id,
[FromBody] CountryPatch countryPatch,
IValidator<CountryPatch> validator,
ICountryMapper mapper,
ICountryService countryService) => {
var validationResult = validator.Validate(countryPatch);
if (validationResult.IsValid)
{
if (countryService.UpdateDescription(
id,
countryPatch.Description
))
return Results.NoContent();
return Results.NotFound();
}
return Results.ValidationProblem(
validationResult.ToDictionary()
);
});
// Delete
app.MapDelete("/countries/{id}", (
int id,
ICountryService countryService) => {
if (countryService.Delete(id))
return Results.NoContent();
return Results.NotFound();
});
POST /countries
چون POST برای Create است، Country ورودی باید ID برابر Null داشته باشد. پس از Validation و Mapping، CreateOrUpdate اجرا میشود. Response موفق 201 Created با CreatedAtRoute است و Validation ناموفق 400 Bad Request تولید میکند. CreatedAtRoute نام Route برابر countryById و ID ساختهشده را میگیرد تا Location منبع جدید ساخته شود.
شکل ۴-۲۷. Response مورد انتظار هنگام Create با POST /countriesLocation Header نشانی Resource تازهساختهشده را به Client میدهد.
GET /countries/{id}
اگر Resource پیدا شود، JSON با 200 OK برگردانده میشود؛ در غیر این صورت 404 Not Found. Endpoint با WithName("countryById") نامگذاری شده تا CreatedAtRoute بتواند URL Location را بسازد.
شکل ۴-۲۸. Response موفق GET /countries/{id}DELETE /countries/{id}
اگر Country وجود داشته و حذف شود، Response برابر 204 No Content است؛ اگر پیدا نشود، 404 Not Found.
شکل ۴-۲۹. Response موفق DELETE /countries/{id}
PUT /countries
PUT در این Example هم Create و هم Update را پوشش میدهد. Validation ناموفق 400 است. اگر country.Id Null باشد Resource ایجاد و 201 Created برگردانده میشود؛ در غیر این صورت Update انجام شده و نویسنده 204 No Content را انتخاب کرده است، هرچند RFC اجازهٔ 200 OK را نیز میدهد.
شکل ۴-۳۰. Response موفق Update با PUT /countriesدر Route مربوط به PUT، ID داخل URL نیست و در Body قرار دارد. PUT یک Operation Idempotent است و Route میتواند ثابت بماند؛ Payload تعیین میکند Create یا Update انجام شود.
PATCH /countries/{id}
PATCH فقط Description را Update میکند. Input Model جداگانهٔ CountryPatch و Validator مربوط به IValidator<CountryPatch> استفاده میشوند. Validation موفق و Update موفق به 204 No Content منجر میشود. اگر Resource وجود نداشته باشد، 404 است؛ PATCH در این مثال Resource جدید نمیسازد. Validation ناموفق 400 با ProblemDetails/ValidationProblem تولید میکند.
در ادامه Mapper و Validator مربوط به PATCH نشان داده میشوند.
Listing 4-25. CountryMapper بهروزشده
using AspNetCore8MinimalApis.Mapping.Interfaces;
using AspNetCore8MinimalApis.Models;
using Domain.DTOs;
namespace AspNetCore8MinimalApis.Mapping;
public class CountryMapper : ICountryMapper
{
public CountryDto? Map(Country country)
{
return country != null ? new CountryDto
{
Id = country.Id,
Name = country.Name,
Description = country.Description,
FlagUri = country.FlagUri,
} : null;
}
public Country? Map(CountryDto country)
{
return country != null ? new Country
{
Id = country.Id,
Name = country.Name,
Description = country.Description,
FlagUri = country.FlagUri,
} : null;
}
public List<Country> Map(List<CountryDto> countries)
{
return countries.Select(Map).ToList();
}
}
Listing 4-26. CountryPatchValidator
using AspNetCore8MinimalApis.Models;
using FluentValidation;
using FluentValidation.Results;
using System.Text.RegularExpressions;
namespace AspNetCore8MinimalApis.Validators;
public class CountryPatchValidator : AbstractValidator<CountryPatch>
{
public CountryPatchValidator()
{
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("{ParameterName} cannot be empty")
.Custom((name, context) =>
{
Regex rg = new Regex("<.*?>"); // Matches HTML tags
if (rg.Matches(name).Count > 0)
{
// Raises an error
context.AddFailure(new ValidationFailure(
"Description",
"The description has invalid content")
);
Code این Listing در صفحهٔ بعد ادامه پیدا میکند و ادامهٔ آن همراه بخش بعدی حفظ خواهد شد.