ASP .NET Core 6 - Explorando Minimal APIs (SQLite)


Hoje vamos continuar explorando o recurso Minimal APIs disponível a partir do .NET 6.0 para projetos ASP .NET Core 6.

O novo recurso do .NET 6.0 - Minimal APIs - para a ASP .NET Core 6 permite, criar APIs com o mínimo de dependência do framework WebAPI e o mínimo de código e arquivos necessários para o desenvolvimento minimalista de APIs.

API Mínima com EF Core, SQLite e Swagger

Neste artigo vamos criar uma API mínima, habilitar o Swagger e usar o EF Core para acessar um banco de dados SQLite.

A título de exercício vamos criar uma aplicação para gerenciar tarefas e realizar o CRUD básico no SQLite.

Eu vou usar o Visual Studio 2022 17.0 Preview 5.0 e criar um projeto usando o template “ASP.NET Core Empty” :

Nota:  Se preferir usar o VS Code pode emitir o comando :   dotnet new web -o MinApi  na ferramenta de linha de comando .NET CLI.

Ao criar o projeto no VS 2022, após selecionar o template, informe o nome do projeto :  Tarefas

A seguir vamos selecionar o Framework .NET 6.0 (que atualmente esta em RC1) e clicar em Create :

E a solução será criada com a seguinte estrutura :

Observe que temos apenas o arquivo appsettings.json e o arquivo Program e a pasta Properties.

Abrindo o arquivo Program temos um código simplificado usando o recurso do Top Level Statements do C# 9.0.

Antes de prosseguir temos que incluir no projeto as referências aos seguintes pacotes:

Podemos incluir os pacotes usando o menu Tools->Nuget Package Manager -> Manage Nuget Package for Solutions, clicar na guia Browse e selecionar e instalar os pacotes.

Nota:  Para instalar as versões dos pacotes em pré-release marque a caixa - Include prerelease - no VS 2022.

Vamos começar definindo no final do arquivo Program a classe Tarefa que representa o nosso modelo de domínio e a classe de contexto TarefaDbContext que representa o contexto do EF Core.

class Tarefa
{
    public int Id { get; set; }
    [Required]
    public string? Titulo { get; set; }
    public bool IsCompleta { get; set; }
}

A classe Tarefa contém apenas 3 propriedades : Id, Titulo e IsCompleta

class TarefaDbContext : DbContext
{
    public TarefaDbContext(DbContextOptions<TarefaDbContext> options)
        : base(options) { }

    public DbSet<Tarefa> Tarefas => Set<Tarefa>();
}

A classe de contexto herda de DbContext e define o mapeamento ORM para a tabela Tarefas.

Agora podemos definir a string de conexão e o registro do contexto como um serviço usando a injeção de dependência.

Para isso vamos incluir o código destacado em azul conforme abaixo no arquivo Program:

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("SqliteConnectionString")
          ?? "Data Source=
tarefas.db";

builder.Services.AddSqlite<TarefaDbContext>(connectionString);

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

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapSwagger();
app.UseSwaggerUI();

app.Run();
 

A partir da versão RC1 do .NET 6 o middleware DeveloperExceptionPageMiddleware agora será registrado como o primeiro middleware se o ambiente atual for de desenvolvimento. Isso significa que IWebHostEnvironment.IsDevelopment() é verdadeiro.

Isso elimina a necessidade de registrar manualmente o middleware pelos desenvolvedores, conforme mostrado no exemplo abaixo.

if (!app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

No código acima também já estamos habilitando o swagger.

Precisamos definir a string de conexão com o SQLite no arquivo appsettings.json :

{
  "ConnectionStrings": {
    "SqliteConnectionString": "Data Source=tarefas.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Como vamos usar o EF Core na abordagem Code-First, desejamos criar o banco de dados no SQLite, e para isso vamos criar um método chamado AsseguraDBExiste com o seguinte código:

async Task AsseguraDBExiste(IServiceProvider services, ILogger logger)
{
    logger.LogInformation("Garantindo que o banco de dados exista e esteja na string de conexão :
'{connectionString}'", connectionString);

    using var db = services.CreateScope().ServiceProvider.GetRequiredService<TarefaDbContext>();

    await db.Database.MigrateAsync();
}

Neste código estamos logando a informação sobre o banco e a string de conexão e resolvendo a dependência do contexto; TarefaDbContext estamos criando uma instância do contexto para a seguir aplicar qualquer migração pendente para o contexto existente usando o método MigrateAsync().

E agora podemos invocar este método :

await AsseguraDBExiste(app.Services, app.Logger);

Neste momento o código do arquivo Program deve estar da seguinte forma:

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("TarefaDb")
          ?? "Data Source=tarefas.db";

builder.Services.AddSqlite<TarefaDbContext>(connectionString);

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

var app = builder.Build();

await AsseguraDBExiste(app.Services, app.Logger);

app.MapGet("/", () => "Hello World!");

app.MapSwagger();
app.UseSwaggerUI();

app.Run();

async Task AsseguraDBExiste(IServiceProvider services, ILogger logger)
{
    logger.LogInformation("Garantindo que o banco de dados exista e esteja na string de conexão :
      '{connectionString}'", connectionString);

    using var db = services.CreateScope().ServiceProvider.GetRequiredService<TarefaDbContext>();
    await db.Database.EnsureCreatedAsync();
    await db.Database.MigrateAsync();
}

class Tarefa
{
    public int Id { get; set; }
    [Required]
    public string? Titulo { get; set; }
    public bool IsCompleta { get; set; }
}

class TarefaDbContext : DbContext
{
    public TarefaDbContext(DbContextOptions<TarefaDbContext> options)
        : base(options) { }

    public DbSet<Tarefa> Tarefas => Set<Tarefa>();
}

Executando o projeto neste momento teremos o resultado da execução:

E a exibição no navegador:

Agora podemos remover a linha de código : app.MapGet("/", () => "Hello World!");

Nota:  No FireFox você pode obter um erro relacionado ao certificado. Isso pode ser corrigido mas é mais fácil usar o Chrome.

Realizando o CRUD

Vamos usar agora os métodos de extensão auxiliares Map{verbo} para configurar os endpoints.

Nota: Este recurso estava presente no .NET 5, mas agora eles dão suporte ao model binding, a integração com a injeção de dependência e acessam facilmente os dados da rota, o que torna a sua utilização mais completa.

Vamos iniciar definindo um endpoint para incluir uma tarefa na tabela tarefas do SQLite usando um MapPost.

Para isso vamos definir o código abaixo:

app.MapPost("/tarefas", async (Tarefa tarefa, TarefaDbContext db) =>
{
    if (tarefa != null)
    {
      
 db.Tarefas.Add(tarefa);
        await db.SaveChangesAsync();

        return Results.Created($"/tarefas/{tarefa.Id}", tarefa);
    }
    else
    {
        return Results.BadRequest("Request inválido");
    }
}
).
WithName("CreateTarefa")
 .ProducesValidationProblem()
 .Produces<Tarefa>(StatusCodes.Status201Created);

O metadado WithName mapeia o nome do endpoint para um operationId em documentos OpenAPI gerados. Observe que o nome do endpoint também é usado ao usar LinkGenerator para gerar URL/links para endpoints. 

O recurso ProducesValidationProblem indica que o endpoint produzirá códigos de status 4xx http e detalhes de erro com application / validationproblem + json content type.

E o metadado Produces<T>(...) indica quais tipos de resposta um método produz, onde cada resposta é a combinação de:
- Um código de status HTTP;
- Um ou mais tipos de conteúdo, por exemplo “Application/json”;
- Um esquema opcional por tipo de conteúdo;

Ao executar o projeto teremos a exibição do endpoint na interface do swagger:



Vamos incluir um registro na tabela tarefas clicando no botão Post e a seguir em Try Out e vamos informar os valores conforme abaixo: (O valor do Id não precisa ser informado)



Ao clicar no botão Execute iremos obter o seguinte resultado:

Temos assim um registro na tabela Tarefas. Vamos repetir o procedimento e incluir mais alguns registros para testar.

Agora que temos alguns registros na tabela vamos definir um endpoint para retornar todos os registros usando um MapGet com seguinte código:

app.MapGet("/tarefas", async (TarefaDbContext db) =>
    await db.Tarefas.ToListAsync())
    .WithName("GetTarefas");

Executando novamente o projeto teremos agora:

Acessando o endpoint Get /tarefas teremos o resultado abaixo:

A seguir vamos incluir um endpoint para retornar uma tarefa pelo seu id usando um MapGet:

app.MapGet("/tarefas/{id}", async (int id, TarefaDbContext db) =>
    await db.Tarefas.FindAsync(id)
        is Tarefa tarefa
            ? Results.Ok(tarefa)
            : Results.NotFound())
    .WithName("GetTarefaById")
    .Produces<Tarefa>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound);

Agora, para concluir vamos definir os endpoints para alterar um registro usando um MapPut e para excluir um registro usando um MapDelete:

1- MapPut (HttpPut)

app.MapPut("/tarefas/{id}", async (int id, Tarefa inputTarefa, TarefaDbContext db) =>
{
    var tarefa = await db.Tarefas.FindAsync(id);

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

    tarefa.Titulo = inputTarefa.Titulo;
    tarefa.IsCompleta = inputTarefa.IsCompleta;

    await db.SaveChangesAsync();

    return Results.NoContent();
})
    .WithName("UpdateTarefa")
    .ProducesValidationProblem()
    .Produces(StatusCodes.Status204NoContent)
    .Produces(StatusCodes.Status404NotFound);

 

2- MapDelete (HttpDelete)

app.MapDelete("/tarefas/{id}", async (int id, TarefaDbContext db) =>
{
    if (await db.Tarefas.FindAsync(id) is Tarefa tarefa)
    {
        db.Tarefas.Remove(tarefa);
        await db.SaveChangesAsync();
        return Results.Ok(tarefa);
    }

    return Results.NotFound();
})
    .WithName("DeleteTarefa")
    .Produces(StatusCodes.Status204NoContent)
    .Produces(StatusCodes.Status404NotFound);

 

Se você quiser deletar todos os registros da tabela pode definir um endpoint usando MapDelete da seguinte forma:

app.MapDelete("/tarefas/delete-tarefas", async (TarefaDbContext db) =>
    Results.Ok(await db.Database.ExecuteSqlRawAsync("DELETE FROM Tarefas")))
    .WithName("DeleteTarefas")
    .Produces<int>(StatusCodes.Status200OK);

No código acima estamos usando o método ExecuteSqlRawAsync que executa o SQL fornecido no banco de dados e retorna o número de linhas afetadas.

Obs:Naturalmente você deve evitar implementar um método para excluir todos os registros do banco em produção.

Com isso temos implementado o mapeamento de todos os endpoints usando os métodos auxiliares, e, o código completo do arquivo Program agora ficou assim:

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("SqliteConnectionString") 
          ?? "Data Source=Tarefas.db";
builder.Services.AddSqlite<TarefaDbContext>(connectionString);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
await AsseguraDBExiste(app.Services, app.Logger);

app.MapGet("/error", () => Results.Problem("Ocorreu um erro.", statusCode: 500))
   .ExcludeFromDescription();
app.MapSwagger();
app.UseSwaggerUI();
app.MapGet("/tarefas", async (TarefaDbContext db) =>
    await db.Tarefas.ToListAsync())
    .WithName("GetTarefas");
app.MapGet("/tarefas/{id}", async (int id, TarefaDbContext db) =>
    await db.Tarefas.FindAsync(id)
        is Tarefa tarefa
            ? Results.Ok(tarefa)
            : Results.NotFound())
    .WithName("GetTarefaById")
    .Produces<Tarefa>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound);
app.MapPost("/tarefas", async (Tarefa tarefa, TarefaDbContext db) =>
{
    if (tarefa != null)
    {
        db.Tarefas.Add(tarefa);
        await db.SaveChangesAsync();
        return Results.Created($"/tarefas/{tarefa.Id}", tarefa);
    }
    else
    {
        return Results.BadRequest("Request inválido");
    }
}
).WithName("CreateTarefa")
 .ProducesValidationProblem()
 .Produces<Tarefa>(StatusCodes.Status201Created);
app.MapPut("/tarefas/{id}", async (int id, Tarefa inputTarefa, TarefaDbContext db) =>
{
    var tarefa = await db.Tarefas.FindAsync(id);
    if (tarefa is null) return Results.NotFound();
    tarefa.Titulo = inputTarefa.Titulo;
    tarefa.IsCompleta = inputTarefa.IsCompleta;
    await db.SaveChangesAsync();
    return Results.NoContent();
})
    .WithName("UpdateTarefa")
    .ProducesValidationProblem()
    .Produces(StatusCodes.Status204NoContent)
    .Produces(StatusCodes.Status404NotFound);
app.MapDelete("/tarefas/{id}", async (int id, TarefaDbContext db) =>
{
    if (await db.Tarefas.FindAsync(id) is Tarefa tarefa)
    {
        db.Tarefas.Remove(tarefa);
        await db.SaveChangesAsync();
        return Results.Ok(tarefa);
    }
    return Results.NotFound();
})
    .WithName("DeleteTarefa")
    .Produces(StatusCodes.Status204NoContent)
    .Produces(StatusCodes.Status404NotFound);
app.Run();
async Task AsseguraDBExiste(IServiceProvider services, ILogger logger)
{
    logger.LogInformation("Garantindo que o banco de dados exista e esteja na string de conexão :" +
        " '{connectionString}'", connectionString);
    using var db = services.CreateScope().ServiceProvider.GetRequiredService<TarefaDbContext>();
    await db.Database.EnsureCreatedAsync();
    await db.Database.MigrateAsync();
}
class Tarefa
{
    public int Id { get; set; }
    [Required]
    public string? Titulo { get; set; }
    public bool IsCompleta { get; set; }
}
class TarefaDbContext : DbContext
{
    public TarefaDbContext(DbContextOptions<TarefaDbContext> options)
        : base(options) { }
    public DbSet<Tarefa> Tarefas => Set<Tarefa>();
}

 

Executando o projeto teremos acesso a todos os endpoints via interface do Swagger :

E poderemos realizar as operações CRUD e gerenciar as informações de tarefas armazenadas no SQLite usando os recursos das Minimal APIs.

Pegue o projeto exemplo aqui:  Tarefas.zip (sem as referências)

"Ele (Jesus) não cometeu pecado algum, e nenhum engano foi encontrado em sua boca.
Quando insultado, não revidava; quando sofria, não fazia ameaças, mas entregava-se àquele que julga com justiça."

1 Pedro 2:22,23

Referências:


José Carlos Macoratti