blexin

Sviluppo
Consulenza e
Formazione IT


Blog

Evolvi la tua azienda

GraphQL: vediamo cos’è, cosa ci permette di fare e come è possibile creare una API in ASP.NET Core con Hot Chocolate

Creiamo la nostra API con GraphQL e Hot Chocolate

Mercoledì 25 Marzo 2020

Quante volte vi è capitato di chiamare una API e ricevere più dati di quelli che vi servivano? Oppure, nel caso opposto, di riceverne di meno e di dover quindi fare più chiamate ad endpoint diversi per avere tutte le informazioni necessarie?
Questi due fenomeni prendono il nome rispettivamente di overfetching e underfetching, e probabilmente rappresentano il più grosso problema dell’uso di una API REST.
Serviva qualcosa che avesse una flessibilità maggiore, in cui il client, con una specifica query, fosse in grado di recuperare tutte le informazioni necessarie e soltanto quelle.

Con GraphQL, tutto questo si può fare.

GraphQL agisce come un middle layer tra la nostre fonte di dati e i nostri client. Può essere considerato come un’alternativa alle REST API, se non una loro evoluzione.
È un query language: ciò significa che, rispetto ad altre architetture di interfaccia che accettano solo query molto rigorose e spesso indirizzate su una singola risorsa, le query GraphQL, sono caratterizzate da un elevato grado di flessibilità. Il Client richiede esattamente i campi che gli interessano.

Questa flessibilità la ritroviamo anche in fase di ampliamenti dell’API: infatti, l’aggiunta di campi restituiti alla nostra struttura non andrà ad impattare sui client esistenti.
La forte tipizzazione è un’altra caratteristica di GraphQL: ogni livello di query corrisponde ad un determinato tipo, e ogni tipo descrive un set di campi disponibili. Questo permette a GraphQL di effettuare una validazione della query prima di eseguirla, restituendo anche messaggi di errore descrittivi.

Ci sono quattro concetti chiave in GraphQL:

  • Schema
  • Resolver
  • Query
  • Mutation

Uno schema GraphQL è composto da tipi di oggetto che definiscono la tipologia di oggetti che è possibile richiedere e i tipi di campi disponibili al suo interno.

I resolver sono i collaboratori che possiamo associare ai campi del nostro schema e che si occuperanno di recuperare i dati di tali campi.

Considerando una tipica CRUD di un dato, possiamo andare a definire il concetto di query e mutation. La prima si occuperà della lettura dell’informazione, mentre la creazione, modifica e cancellazione sono mansioni prese in carico dalle mutation.

Proviamo ora a creare un’applicazione in grado di eseguire una CRUD con GraphQL.
Lo facciamo in ASP.NET Core con Hot Chocolate, una libreria che permette di creare un implementazione server di GraphQL.

Creiamo un progetto ASP.NET Core Web, e aggiungiamo mediante pacchetti Nuget la libreria HotChocolate e HotChocolate.AspNetCore. Si è dimostrata molto utile anche la libreria HotChocolate.AspNetCore.Playground, che permette di testare nel browser l’API che stiamo sviluppando.

Al file di Startup.cs andiamo ad aggiungere le istruzioni necessarie per configurare GraphQL.

namespace DemoGraphQL
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGraphQL(s => SchemaBuilder.New()
                .AddServices(s)
                .AddType<AuthorType>()
                .AddType<BookType>()
                .AddQueryType<Query>()
                .Create());
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UsePlayground();
            }

            app.UseGraphQL("/api");
        }
    }
}

Definiamo AuthorType e BookType, che saranno i tipi esposti dalla nostra Api.
In queste due classi andiamo ad indicare quali campi delle classi di dominio Author e Book verranno esposti.

public class AuthorType : ObjectType<Author>
{
    protected override void Configure(IObjectTypeDescriptor<Author> descriptor)
    {
        descriptor.Field(a => a.Id).Type<IdType>();
        descriptor.Field(a => a.Name).Type<StringType>();
        descriptor.Field(a => a.Surname).Type<StringType>();
    }
}
     
public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Surname { get; set; }
}

public class BookType : ObjectType<Book>
{
    protected override void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.Id).Type<IdType>();
        descriptor.Field(b => b.Title).Type<StringType>();
        descriptor.Field(b => b.Price).Type<DecimalType>();
    }
}
 

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal Price { get; set; }
}

A questo punto, possiamo creare la classe Query, iniziando con il definire gli autori.

public class Query
{
    private readonly IAuthorService _authorService;
    public Query(IAuthorService authorService)
    {
        _authorService = authorService;
    }
    [UsePaging(SchemaType = typeof(AuthorType))]
    public IQueryable<Author> Authors => _authorService.GetAll();
}

Con l’annotazione UsePaging, stiamo istruendo GraphQL in modo tale che gli autori restituiti dall’apposito service dovranno essere resi disponibili in forma paginata e nel tipo AuthorType definito in precedenza.
In questo modo, avviando l’applicazione e andando nel playground, possiamo effettuare la seguente query e vedere il risultato.

Aggiungendo la libreria HotChocolate.Types e HotChocolate.Types.Filters è possibile aggiungere una nuova annotation per abilitare i filtri.

[UsePaging(SchemaType = typeof(AuthorType))]
[UseFiltering]
public IQueryable<Author> Authors => _authorService.GetAll();

Anche con la query sui libri otteniamo lo stesso risultato

[UsePaging(SchemaType = typeof(BookType))]
[UseFiltering]
public IQueryable<Book> Books => _bookService.GetAll();

In questo momento, libri e autori non sono legati tra loro, ma in un’applicazione reale vogliamo poter ottenere le informazioni degli autori quando facciamo una query sui libri e allo stesso modo vogliamo ottenere la lista dei libri scritti quando facciamo una query sugli autori.

Modifichiamo la classe Book per aggiungere l’id autore:

public class Book
{
    public int Id { get; set; }
    public int AuthorId { get; set; }
    public string Title { get; set; }
    public decimal Price { get; set; }
}

A questo punto, possiamo utilizzare uno degli elementi elencati prima di GraphQL: i resolver. Implementiamo per primo quello che ci permette di ottenere informazioni dell’autore sulla query dei libri.

public class BookType : ObjectType<Book>
{
    protected override void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.Id).Type<IdType>();
        descriptor.Field(b => b.Title).Type<StringType>();
        descriptor.Field(b => b.Price).Type<DecimalType>();
        descriptor.Field<AuthorResolver>(t => t.GetAuthor(default, default));
    }
}

Abbiamo aggiunto un nuovo campo allo schema BookType con descriptor.Field e gli abbiamo detto di risolverlo mediante il metodo GetAuthor di AuthorResolver.

public class AuthorResolver
{
    private readonly IAuthorService _authorService;

    public AuthorResolver([Service]IAuthorService authorService)
    {
        _authorService = authorService;
    }

    public Author GetAuthor(Book book, IResolverContext ctx)
    {
        return _authorService.GetAll().Where(a => a.Id == book.AuthorId).FirstOrDefault();
    }
}

Allo stesso modo, possiamo definire un nuovo campo con rispettivo resolver per aggiungere agli autori anche i libri che hanno scritto.

public class AuthorType : ObjectType<Author>
{
    protected override void Configure(IObjectTypeDescriptor<Author> descriptor)
    {
        descriptor.Field(a => a.Id).Type<IdType>();
        descriptor.Field(a => a.Name).Type<StringType>();
        descriptor.Field(a => a.Surname).Type<StringType>();
        descriptor.Field<BookResolver>(t => t.GetBooks(default, default));
    }
}


public class BookResolver
{
    private readonly IBookService _bookService;

    public BookResolver([Service]IBookService bookService)
    {
        _bookService = bookService;
    }
    public IEnumerable<Book> GetBooks(Author author, IResolverContext ctx)
    {
        return _bookService.GetAll().Where(b => b.AuthorId == author.Id);
    }
}

Per completare la CRUD con le operazioni di creazione e cancellazione, dobbiamo implementare quella che viene definita mutation. Non è altro che una classe con metodi che indicano le operazioni possibili.
Deve essere registrata mediante la seguente istruzione AddMutationType<Mutation>() che va aggiunta al blocco di configurazione di GraphQL nello Startup.cs

services.AddGraphQL(s => SchemaBuilder.New()
    .AddServices(s)
    .AddType<AuthorType>()
    .AddType<BookType>()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .Create());

Iniziamo aggiungendo un metodo che ci permetta di inserire un nuovo libro:

public class Mutation
{
    private readonly IBookService _bookService;

    public Mutation(IBookService bookService)
    {
        _bookService = bookService;
    }

    public Book CreateBook(CreateBookInput inputBook)
    {
        return _bookService.Create(inputBook);
    }
}

creiamo anche la classe di input che dovremo inserire nella query

public class CreateBookInput
{
    public string Title { get; set; }
    public decimal Price { get; set; }
    public int AuthorId { get; set; }
}

Il service non dovrà fare altro che creare una nuova istanza di Book, aggiungerla alla sua lista e restituire il libro creato.
Dal playground, possiamo testare la nuova funzionalità passando la query come da shot.

Aggiungiamo ora la funzionalità di rimozione di un libro.
Così come per la creazione, occorre aggiungere un metodo alla mutation.

public Book DeleteBook(DeleteBookInput inputBook)
{
    return _bookService.Delete(inputBook);
}

La classe DeleteBookInput contiene solo una property di tipo int che rappresenta l’Id del libro che si vuole eliminare.
Una possibile implementazione del metodo Delete del Service è la seguente :

public Book Delete(DeleteBookInput inputBook)
{
    var bookToDelete = _books.Single(b => b.Id == inputBook.Id);
    _books.Remove(bookToDelete);
    return bookToDelete;
}

Provando a rieseguire la query, otterremo un messaggio di errore relativo all’eccezione lanciata dal metodo Single. Possiamo rendere il messaggio di errore più descrittivo, così da non generare dubbi in chi chiama la nostra API.

Creamo una nuova Exception

public class BookNotFoundException : Exception
{
    public int BookId { get; internal set; }
}

E facciamo in modo che venga lanciata questa, nel caso in cui non sia stato possibile trovare il libro da cancellare.

public Book Delete(DeleteBookInput inputBook)
{
    var bookToDelete = _books.FirstOrDefault(b => b.Id == inputBook.Id);
    if (bookToDelete == null)
    throw new BookNotFoundException() { BookId = inputBook.Id };
    _books.Remove(bookToDelete);
    return bookToDelete;
}

Ora creiamo una classe che implementa IErrorFilter messo a disposizione da HotChocolate che intercetta BookNotFoundException e aggiunge all’errore le informazioni che vogliamo restituire all’utente.

public class BookNotFoundExceptionFilter : IErrorFilter
{
    public IError OnError(IError error)
    {
         if (error.Exception is BookNotFoundException ex)
             return error.WithMessage($"Book with id {ex.BookId} not found");
            
         return error;
    }
}

Infine, andiamo a registrare questa classe nello Startup

services.AddErrorFilter<BookNotFoundExceptionFilter>();

La nostra API è finalmente completa, il codice come sempre è sul mio github https://github.com/AARNOLD87/GraphQLWithHotChocolate

Al termine del progetto ho pensato: sembra tutto fantastico, perché non ho utilizzato GraphQL per tutte le API? C’è qualche svantaggio che non ho considerato?

In questa demo, abbiamo implementato la gestione degli errori. In API REST, quando qualcosa non va a buon fine, ci aspettiamo uno Status Code diverso da 200. In GraphQL, invece, otterremo sempre 200, anche in caso di errore. Questo vuol dire che il client dovrà basarsi esclusivamente sul contenuto della risposta, senza poter prendere decisioni preventive in base allo stato ricevuto dalla richiesta.

Altro aspetto che in una demo ovviamente non viene fuori, è il discorso legato al caching.
Non c’è un supporto di cache built-in e implementarlo non è così semplice come accade con le API REST. Un aspetto da non sottovalutare.

Spero di avervi incuriosito.

Al prossimo articolo!

Autore

Servizi

Evolvi la tua azienda