Перейти к основному содержимому

Практикум WPF — сервер ASP.NET Core Web API

Разработчику Архитектору

Практикум, шаг 3 из 6. Поднимаем TaskDesk.Api — REST-сервер для десктоп-клиента. Базовый цикл ASP.NET — 4511; Minimal API — 4517; REST — 1151.


Контракт API

Ресурс — задача (Task). Базовый префикс /api/v1/tasks.

МетодURLТелоОтвет
GET/api/v1/tasks200 массив TaskDto
GET/api/v1/tasks/{id}200 или 404
POST/api/v1/tasks{ "title", "status" }201 + Location
PUT/api/v1/tasks/{id}полный TaskDto200 или 404
DELETE/api/v1/tasks/{id}204 или 404

Фильтр (опционально): GET /api/v1/tasks?status=Todo.


Структура проекта

mkdir TaskDesk
cd TaskDesk
dotnet new sln -n TaskDesk
dotnet new webapi -n TaskDesk.Api -o src/TaskDesk.Api --use-controllers
dotnet new classlib -n TaskDesk.Core -o src/TaskDesk.Core
dotnet sln add src/TaskDesk.Api src/TaskDesk.Core
dotnet add src/TaskDesk.Api reference src/TaskDesk.Core
TaskDesk/
├── TaskDesk.sln
└── src/
├── TaskDesk.Api/
│ ├── Program.cs
│ ├── Controllers/TasksController.cs
│ └── Services/InMemoryTaskStore.cs
└── TaskDesk.Core/
├── Models/TaskItem.cs
└── Contracts/TaskDto.cs

DTO — контракт JSON

namespace TaskDesk.Core.Contracts;

public record TaskDto(
Guid Id,
string Title,
string Status,
DateTimeOffset CreatedAt);

public record CreateTaskRequest(string Title, string Status = "Todo");

public record UpdateTaskRequest(string Title, string Status);

DTO отделены от доменной модели — клиент WPF и тесты завязаны на стабильный JSON, а не на внутренние классы сервера.


Хранилище и DI

public interface ITaskStore
{
IReadOnlyList<TaskItem> GetAll(string? statusFilter);
TaskItem? GetById(Guid id);
TaskItem Add(TaskItem item);
bool Update(TaskItem item);
bool Delete(Guid id);
}

public sealed class InMemoryTaskStore : ITaskStore
{
private readonly List<TaskItem> _items = new();
private readonly object _lock = new();

public IReadOnlyList<TaskItem> GetAll(string? statusFilter)
{
lock (_lock)
{
var q = _items.AsEnumerable();
if (!string.IsNullOrEmpty(statusFilter))
q = q.Where(t => t.Status.ToString() == statusFilter);
return q.OrderByDescending(t => t.CreatedAt).ToList();
}
}
// Add, Update, Delete — с lock и Guid
}

Регистрация в Program.cs:

builder.Services.AddSingleton<ITaskStore, InMemoryTaskStore>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

Для учебного проекта достаточно памяти; прод-вариант — EF Core + SQLite (453).


Контроллер REST

[ApiController]
[Route("api/v1/[controller]")]
public class TasksController : ControllerBase
{
private readonly ITaskStore _store;

public TasksController(ITaskStore store) => _store = store;

[HttpGet]
public ActionResult<IEnumerable<TaskDto>> GetAll([FromQuery] string? status)
{
var items = _store.GetAll(status).Select(Map);
return Ok(items);
}

[HttpGet("{id:guid}")]
public ActionResult<TaskDto> GetById(Guid id)
{
var item = _store.GetById(id);
return item is null ? NotFound() : Ok(Map(item));
}

[HttpPost]
public ActionResult<TaskDto> Create([FromBody] CreateTaskRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });

var entity = new TaskItem
{
Title = request.Title.Trim(),
Status = Enum.Parse<TaskStatus>(request.Status, ignoreCase: true)
};
_store.Add(entity);
var dto = Map(entity);
return CreatedAtAction(nameof(GetById), new { id = dto.Id }, dto);
}

[HttpPut("{id:guid}")]
public ActionResult<TaskDto> Update(Guid id, [FromBody] UpdateTaskRequest request)
{
var existing = _store.GetById(id);
if (existing is null) return NotFound();
existing.Title = request.Title.Trim();
existing.Status = Enum.Parse<TaskStatus>(request.Status, ignoreCase: true);
_store.Update(existing);
return Ok(Map(existing));
}

[HttpDelete("{id:guid}")]
public IActionResult Delete(Guid id) =>
_store.Delete(id) ? NoContent() : NotFound();

private static TaskDto Map(TaskItem t) =>
new(t.Id, t.Title, t.Status.ToString(), t.CreatedAt);
}

Program.cs — pipeline, Swagger, CORS

Десктоп-клиент на другом origin (или localhost с другим портом) требует CORS:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
options.AddPolicy("TaskDeskClient", policy =>
policy.WithOrigins("http://localhost:5000") // dev WPF через proxy или *
.AllowAnyHeader()
.AllowAnyMethod());
});

// ... AddControllers, Swagger, ITaskStore

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseCors("TaskDeskClient");
app.MapControllers();

app.Run();
CORS и нативный WPF

WPF-приложение не является браузеромHttpClient не применяет CORS так же, как Chrome. Политика CORS нужна, если клиент ходит через WebView или вы тестируете из Swagger/браузера. Для чистого HttpClient из WPF достаточно корректного URL и TLS; CORS оставляем для единообразия с веб-инструментами и будущим Blazor Hybrid.

Запуск:

cd src/TaskDesk.Api
dotnet run --urls http://localhost:5100

Swagger UI: http://localhost:5100/swagger.


Проверка вручную

curl -X GET http://localhost:5100/api/v1/tasks
curl -X POST http://localhost:5100/api/v1/tasks `
-H "Content-Type: application/json" `
-d "{\"title\":\"Настроить Prism\"}"

Ожидаем 201 и JSON с id.


Ошибки и коды

КодКогда
400Пустой заголовок, неверный enum статуса
404Нет задачи с таким id
500Необработанное исключение — логируем, клиенту краткий JSON

Единый формат ошибки упрощает отображение в WPF (ErrorMessage в ViewModel).


Чек-лист шага 3

  • API отвечает на GET и POST /api/v1/tasks.
  • Swagger открывается в Development.
  • DTO в TaskDesk.Core совпадают с тем, что ожидает клиент.
  • Сервис зарегистрирован через DI.

Дальше: Клиент WPF на Prism.


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").