ASP .NET Core - Minimal API com PostgreSQL e EF Core - I


Neste artigo veremos como criar uma API usando a abordagem das Minimal APIs para realizar um CRUD básico em um banco de dados PostgreSQL usando o EF Core.

Podemos dizer que vamos implementar um CRUD com base em um microsserviço RESTful usando o recurso das APIs mínimas da ASP.NET Core no ambiente do  .NET 6.


Neste artigo você aprenderá como implementar Criar, Ler, Atualizar e Excluir endpoints REST.

Para resumir, nossa API web mínima vai usar os seguintes recursos:

Para acompanhar o artigo você terá que ter os seguintes recursos:

Criando o projeto ASP.NET Core Web API na pasta API

Abra o VS 2022, clique em New Project e selecione o template ASP .NET Core Web API e clique em Next;

Informe o nome CatalogoApi e clique em Next;

A seguir selecione o Framework, Authentication Type e demais configurações conforme mostra a figura:

Obs: Note que vamos usar as minimal APIs.

Clique em Create.

Com o projeto criado vamos criar as pastas Models e Context no projeto e incluir os seguintes pacotes:

Na pasta Models vamos criar as classes Produto e Categoria que representam o nosso modelo de domínio:

1- Categoria

public class Categoria
{
   public int CategriaId { get; set; }
   public string? Nome { get; set; }
   public string? Descricao { get; set; }

   public ICollection<Produto> Produtos { get; set; }
}

2- Produto

using System.Text.Json.Serialization;

public class Produto
{
        public int ID { get; set; }
        public string? Nome { get; set; }
        public string? Descricao { get; set; }
        public decimal Preco { get; set; }
        public string? ImagemUrl { get; set; }
        public DateTime DataCompra { get; set; }
        public int Estoque { get; set; }

        public int CategoriaId { get; set; }
        [JsonIgnore]
        public Categoria Categoria { get; set; }
}

Observe que eu defini as propriedades de navegação nas entidades para que o EF Core possa inferir o relacionamento entre Categoria e Produto.  Note também que definimos o atributo [JsonIgnore] para ignorar a propriedade lógica na serialização e na desserialização.

A seguir vamos criar nesta pasta a classe de contexto AppDbContext que herda de DbContext:

using CatalogoApi.Models;
using Microsoft.EntityFrameworkCore;

namespace CatalogoApi.Context
{
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
        { }

        public DbSet<Produto>? Produtos { get; set; }
        public DbSet<Categoria>? Categorias { get; set; }

        protected override void OnModelCreating(ModelBuilder mb)
        {
            mb.Entity<Produto>().HasKey(c => c.ProdutoId);
            mb.Entity<Produto>().Property(c => c.Nome).HasMaxLength(100).IsRequired();
            mb.Entity<Produto>().Property(c => c.Descricao).HasMaxLength(150);
            mb.Entity<Produto>().Property(c => c.Imagem).HasMaxLength(100);
            mb.Entity<Produto>().Property(c => c.Preco).HasPrecision(14, 2);

            mb.Entity<Categoria>().HasKey(c => c.CategoriaId);
            mb.Entity<Categoria>().Property(c => c.Nome).HasMaxLength(100).IsRequired();
            mb.Entity<Produto>().Property(c => c.Descricao).HasMaxLength(150).IsRequired();

            mb.Entity<Produto>()
                .HasOne<Categoria>(c=> c.Categoria)
                .WithMany(p=> p.Produtos)
                .HasForeignKey(c => c.CategoriaId);

        }
    }
}

Na definição do contexto estamos usando a Fluent API para definir o mapeamento das propriedades das entidade Produto e Categoria e também definindo o relacionamento um-para-muitos entre Categoria e Produto.

No arquivo appsettings.json vamos definir a string de conexão com o banco CatalogoDB do PostgreSQL que iremos criar:

{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Pooling=true;Database=CatalogoDB;User Id=
seu_usuario;Password=sua_senha;"
},

"Logging": {
  "LogLevel": {
     "Default": "Information",
         "Microsoft.AspNetCore": "Warning"
       }
},
"AllowedHosts": "*"
}

No arquivo Program vamos registrar o serviço do contexto usando o provedor NpgSQL e obtendo a string de conexão:


var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<AppDbContext>(options =>
                  options.UseNpgsql(connectionString));

builder.Services.AddDatabaseDeveloperPageExceptionFilter();
 

Aplicando o Migrations na abordagem Code-First

Já temos os pacotes instalados para trabalhar com o Migrations e vamos usar a abordagem Code-First do EF Core para criar o banco e as tabelas.

Para isso vamos usar a ferramenta de linha de comando do EF Core. A seguir temos os comandos para instalar ou atualizar a ferramenta:

  1. Instalar -  dotnet tool install --global dotnet-ef
     
  2. Atualizar -  dotnet tool update --global dotnet-ef

Esta ferramenta pode ser usada para os fins mencionados abaixo:

Para criar uma nova migração – isso é para gerar um novo arquivo de migração
Para remover uma migração existente – isso é para remover (excluir) o arquivo de migração.
Para aplicar migrações a um banco de dados – isso é equivalente a criar um novo banco de dados ou alterar o existente
Para remover o banco de dados existente - isso é equivalente a descartar o banco de dados existente

Vamos então criar uma migração inicial estando na pasta do projeto e digitando o comando:

 dotnet ef migrations add MigracaoInicial

Este comando vai criar a pasta Migrations no projeto contendo os arquivos 20220203113849_MigracaoInicial.cs que é o arquivo de script contendo os comandos que serão usados para gerar o banco e as tabelas.

Após verificar o arquivo gerado para aplicar a migração podemos usar o comando:

dotnet ef database update MigracaoInicial

Este comando vai gerar o banco e as tabelas no PostgreSQL e podemos usar o PgAdmin para fazer a verificação conforme mostra a figura:

 

Com isso já temos o banco de dados CatalogoDB e as tabelas Produtos e Categorias criadas no PostgreSQL e podemos continuar.

Criando os endpoints RESTFul da minimal API

Começaremos a criar agora os endpoints da nossa API RESTful para realizar o CRUD básico em Categorias e Produtos.

A seguir temos uma visão geral dos endpoints que iremos criar e dos verbos HTTP que serão usados bem com os status code envolvidos:

Verbo HTTP Endpoint Status Code(s) Descrição
POST /categorias 201 Created Criar um recurso :
GET /categorias/{id:int} 200 OK, 404 Not Found Obtém um recurso pelo seu Id
GET /categorias 200 OK Retorna todos os recursos
PUT /categorias/{id:int} 200 OK, 404 Not Found, 400 Bad Request Atualiza um recurso
DELETE /categorias/{id:int} 204 No Content Deleta um recurso

Os endpoints para Produtos seguem o mesma esquema.

1 - Criando um novo recurso - POST

Vamos iniciar com o endpoint para cri    ar um novo recurso que pode ser Categoria ou Produto.

Para implementar a criação do endpoint para criar um recurso o verbo HTTP que usaremos será o POST.

A solicitação POST enviará uma representação JSON do objeto Produto ou Categoria como payload para a  /categorias ou /produtos.

Após a criação do recurso ser bem sucedido, o corpo da resposta terá um novo recurso Categoria/Produto criado junto com o id.

O campo id é uma chave primária no banco de dados, e é gerado automaticamente pelo banco de dados.

O código de status de resposta será 201 Create.

Além disso, a resposta terá um cabeçalho Location definido para o recurso para ajudar a encontrar a nova nota criada.

Verbo HTTP     Endpoint     Status Code    Descrição da operação
POST /categorias 201 Created Cria uma categoria
POST /produtos 201 Created Cria um produto

Para isso vamos usar o seguinte código:

app.MapPost("/categorias/", async (Categoria categoria, AppDbContext db) =>
{
   db.Categorias.Add(categoria);
   await db.SaveChangesAsync();

    return Results.Created($"/categorias/{categoria.CategoriaId}", categoria);
});

Estamos usando o método de extensão MapPost em uma instância de app onde usamos dois parâmetros:

  1. O primeiro é uma string que representa o padrão para o endpoint - '/categorias'
  2. O segundo é um delegate para tratar o request Post;

Em nosso caso estamos fazendo um tratamento assíncrono que usa dois parâmetros de entrada:

  1. No primeiro temos a Categoria que representa o payload JSON de entrada no body do request;
  2. O segundo realizar a injeção de dependência do contexto do banco de dados - AppDbContext -  disponível como parte de DbContext do EF Core;

Como não estamos usando classes Controllers nem seus construtores, estamos realizando a injeção de dependência no parâmetro de entrada do delegate.

Assim, o MapPost tem o handler  injetado com o payload da Categoria e com a dependência DbContext de AppDbContext injetada adicionará a Categoria recebida ao banco de dados.

Como o handler de delegate está operando no modo assíncrono, aguardaremos o contexto do banco de dados para salvar as alterações no banco de dados.

Depois de criar com sucesso um registro de Categoria no banco de dados, construiremos a resposta com o código de status 201 Created juntamente com o local que representa a url da nova Categoria que acabou de ser criada usando o código :

 return Results.Created($"/categorias/{categoria.CategoriaId}", categoria);

Executando o projeto neste momento teremos a visualização abaixo:

Para Produtos o código do endpoint é praticamente o mesmo :

app.MapPost("/produtos/", async (Produto produto, AppDbContext db) =>
{
   db.Produtos.Add(produto);
   await db.SaveChangesAsync();

   return Results.Created($"/produtos/{produto.ProdutoId}", produto);
});

2 - Lendo dados de um Categoria/Produdo - GET

Para criar o endpoint que lê uma única Categoria/Produto pelo seu ID vamos usar o verbo HTTP GET.

Verbo HTTP     Endpoint     Status Code    Descrição da operação
GET /categorias/{id:int} 200 OK, 404 Not Found Ele uma categoria pelo seu id
GET /produtos/{id:int}        200 OK, 404 Not Found    Ele um produto pelo seu id

Se o id solicitante do recurso for encontrado no banco de dados, a resposta terá uma representação JSON do objeto Categoria/Produto com o código de status 200 OK. Se o id não for encontrado, o código de status será 404 Not Found.

app.MapGet("/categorias/{id:int}", async (int id, AppDbContext db) =>
{
    return await db.Categorias.FindAsync(id)
         is Categoria categoria
                     ? Results.Ok(categoria)
                      : Results.NotFound();
});

Neste código estamos chamando MapGet para realizar a operação GET.

O padrão de url também possui um parâmetro de rota {id:int} para indicar o id da Categoria que usamos para encontrar a Categoria.

3- Ler dados de todas as Categorias/Produtos - GET

Agora vamos implementar um endpoint para recuperar a coleção de objetos Categoria/Produto.

O verbo HTTP usado será o  GET e o corpo da resposta será um array de representação JSON de objetos Categoria/Produto e o código de status será 200 OK.

Verbo HTTP     Endpoint     Status Code    Descrição da operação
GET /categorias 200 OK Retorna todas as categorias
GET /produtos  200 OK Retorna todos os produtos

Aqui podemos retornar apenas as categorias ou as categorias com os seus produtos, neste caso usamos a cláusula include :


app.MapGet("/categorias", async (AppDbContext db) => await db.Categorias.ToListAsync());
 
app.MapGet("/categorias", async (AppDbContext db) => await db.Categorias
                                                                                                  .Include(p=> p.Produtos)
                                                                                                  .ToListAsync());

4- Atualizar uma Categoria/Produto - PUT

Para atualizar um objeto Categoria/Produto podemos usar o verbo HTTP PUT. Vamos definir um endpoint para atualizar um objeto com base no seu id.

Verbo HTTP     Endpoint     Status Code    Descrição da operação
PUT /categorias/{id:int} 200 OK, 404 Not Found Atualiza uma categoria
PUT /produtos/{id:int}   200 OK, 404 Not Found Atualiza um produto

Se o id solicitante do recurso for encontrado no banco de dados, a resposta terá uma representação JSON do objeto com código de status 200 OK. Se o id não for encontrado, o código de status será 404 Not Found.

 app.MapPut("/categorias/{id:int}", async (int id, Categoria categoria, AppDbContext db) =>
 {
                if (categoria.CategoriaId != id)
                {
                    return Results.BadRequest();
                }

                var categoriaDB = await db.Categorias.FindAsync(id);

                if (categoriaDB is null) return Results.NotFound();

                categoriaDB.Nome = categoria.Nome;
                categoriaDB.Descricao = categoria.Descricao;

                await db.SaveChangesAsync();
                return Results.Ok(categoriaDB);
 });

Para Produtos o código é o seguinte:

            app.MapPut("/produtos/{id:int}", async (int id, Produto produto, AppDbContext db) =>
            {
                if (produto.ProdutoId != id)
                {
                    return Results.BadRequest();
                }

                var produtoDB = await db.Produtos.FindAsync(id);

                if (produtoDB is null) return Results.NotFound();

                produtoDB.Nome = produto.Nome;
                produtoDB.Descricao = produto.Descricao;
                produtoDB.Preco = produto.Preco;
                produtoDB.DataCompra = produto.DataCompra;
                produtoDB.Estoque = produto.Estoque;
                produtoDB.Imagem = produto.Imagem;
                produtoDB.CategoriaId = produto.CategoriaId;

                await db.SaveChangesAsync();
                return Results.Ok(produtoDB);
            });

No código usado estamos chamando MapPut para realizar a operação PUT.

O padrão de url também possui um parâmetro de rota {id:int} para indicar o id do objeto.

Verificamos se o id do parâmetro de caminho corresponde ao id da Categoria obtida da carga JSON. Quando esses id não correspondem, o resultado é 400 Bad Request. A seguir encontramos objeto com base no ID de entrada.

Se o objeto com o id que está sendo solicitado para atualização não existir no banco de dados, o resultado será 404 Not Found.

Quando o objeto for encontrado, vamos atualizar seus campos com a carga útil recebida e salvaremos o objeto atualizado no banco de dados.

Finalmente respondemos com a objeto que foi atualizado como uma resposta JSON com o código de status 200 OK.

5- Deletar um objeto Categoria/Produto - DELETE

Para excluir uma Categoria/Produto do banco de dados, usaremos o verbo HTTP DELETE.

O endpoint excluirá uma única Categoria/Produto do banco de dados com base em seu id.

Verbo HTTP     Endpoint     Status Code    Descrição da operação
DELETE /categorias/{id:int} 204 No Content Deleta uma categoria
DELETE /produtos/{id:int}   204 No Content      Deleta a um produto

A solicitação DELETE terá um id em seu URL do endpoint.

 app.MapDelete("/categorias/{id:int}", async (int id, AppDbContext db) =>
 {

    var categoria = await db.Categorias.FindAsync(id);

    if (categoria is not null)
    {
        db.Categorias.Remove(categoria);
        await db.SaveChangesAsync();
     }

     return Results.NoContent();

 });

Com isso temos a implementação dos endpoints para realizar o CRUD em Categoria e Produto e ao final o código da nossa Minimal API ficou assim:

using CatalogoApi.Context;
using CatalogoApi.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<AppDbContext>(options =>
              options.UseNpgsql(connectionString));

builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.MapPost("/categorias/", async (Categoria categoria, AppDbContext db) => {
    db.Categorias.Add(categoria);
    await db.SaveChangesAsync();

    return Results.Created($"/categorias/{categoria.CategoriaId}", categoria);
});

app.MapGet("/categorias", async (AppDbContext db) => await db.Categorias.ToListAsync());

app.MapGet("/categorias/{id:int}", async (int id, AppDbContext db) =>
{
    return await db.Categorias.FindAsync(id)
            is Categoria categoria
                ? Results.Ok(categoria)
                : Results.NotFound();
});

app.MapPut("/categorias/{id:int}", async (int id, Categoria categoria, AppDbContext db) =>
{
    if (categoria.CategoriaId != id)
    {
        return Results.BadRequest();
    }

    var categoriaDB = await db.Categorias.FindAsync(id);

    if (categoriaDB is null) return Results.NotFound();

    categoriaDB.Nome = categoria.Nome;
    categoriaDB.Descricao = categoria.Descricao;

    await db.SaveChangesAsync();
    return Results.Ok(categoriaDB);
});

app.MapDelete("/categorias/{id:int}", async (int id, AppDbContext db) => {

    var categoria = await db.Categorias.FindAsync(id);

    if (categoria is not null)
    {
        db.Categorias.Remove(categoria);
        await db.SaveChangesAsync();
    }

    return Results.NoContent();

});

app.MapPost("/produtos/", async (Produto produto, AppDbContext db) => {
    db.Produtos.Add(produto);
    await db.SaveChangesAsync();

    return Results.Created($"/produtos/{produto.ProdutoId}", produto);
});

app.MapGet("/produtos", async (AppDbContext db) => await db.Produtos.ToListAsync());

app.MapGet("/produtos/{id:int}", async (int id, AppDbContext db) =>
{
    return await db.Produtos.FindAsync(id)
            is Produto produto
                ? Results.Ok(produto)
                : Results.NotFound();
});

app.MapPut("/produtos/{id:int}", async (int id, Produto produto, AppDbContext db) =>
{
    if (produto.ProdutoId != id)
    {
        return Results.BadRequest();
    }

    var produtoDB = await db.Produtos.FindAsync(id);

    if (produtoDB is null) return Results.NotFound();

    produtoDB.Nome = produto.Nome;
    produtoDB.Descricao = produto.Descricao;
    produtoDB.Preco = produto.Preco;
    produtoDB.DataCompra = produto.DataCompra;
    produtoDB.Estoque = produto.Estoque;
    produtoDB.Imagem = produto.Imagem;
    produtoDB.CategoriaId = produto.CategoriaId;

    await db.SaveChangesAsync();
    return Results.Ok(produtoDB);
});

app.MapDelete("/produtos/{id:int}", async (int id, AppDbContext db) => {

    var produto = await db.Produtos.FindAsync(id);

    if (produto is not null)
    {
        db.Produtos.Remove(produto);
        await db.SaveChangesAsync();
    }

    return Results.NoContent();

});

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

app.Run();

Executando o projeto teremos o resultado abaixo:

O que podemos concluir ?

O código gerado para gerenciar o CRUD para apenas duas entidades em um único arquivo ficou extenso e a tendência é aumentar ainda mais quando incluirmos outros recursos e precisarmos realizar outras configurações.

Assim, como o nome diz, as minimal APIs, devem ser usadas para projetos pequenos, protótipos ou provas visto que em um projeto complexo teremos um código que não será muito fácil manter.

Dessa forma devemos ver as minimal APIs como mais um recurso que podemos usar e não como um padrão que devemos seguir.

Naturalmente podemos organizar o código apresentado aqui e iremos fazer isso mais adiante, mas antes precisamos implementar a segurança em nossa API e vamos fazer isso no próximo artigo.

Pegue o projeto completo aqui :  CatalogoApi_Inicio.zip

"Porque, se vivemos, para o Senhor vivemos; se morremos, para o Senhor morremos. De sorte que, ou vivamos ou morramos, somos do Senhor."
Romanos 14:8

Referências:


José Carlos Macoratti