GraphQLNr: 2 W poprzednim wpisie stworzyliśmy podstawowe API w GraphQL. Nasz sklep smoków jest na chwilę obecną bardzo prymitywny. Nasza smok nie zawiera w sobie złożonych typów. Nie zastosowaliśmy wzorca "Data Loader" w GraphQL, aby utrzymać strukturę zapytań. Na razie nasze API w sumie obsługuje tylko jedno zapytanie. Pobierz wszystkie smoki, które są na sprzedaż.

Jak dodać parametry do naszych zapytań w GraphQL?

Jak dodać autoryzację do swojej Schema.

Rozbudujmy więc nasze api.

W poprzednim wpisie dodaliśmy parę pól Z encji Smoków, które są wystawione na Schemie.

public class DragonType : ObjectGraphType<Dragon>
{
    public DragonType()
    {
        Field(t => t.Id);
        Field(t => t.Name).Description("The name of the draon");
        Field(t => t.Description);
        Field(t => t.IntroducedAt).Description("When the dragon was first introduced in the catalog");
        Field(t => t.Price);
        Field(t => t.Rating).Description("The (max 5) star customer rating");
        Field<ColorDragonType>("Color", "The color of dragon");
        Field<BreathDragonType>("Breath", "The breath of dragon");
        //Field(t => t.Active);
        //Field(t => t.Age);
        //Field(t => t.CrewCapacity);
        //Field(t => t.DiameterMeters);
        //Field(t => t.DoneFirstFlight);
        //Field(t => t.DryMassKg);
        //Field(t => t.OrbitDurationYears);
        //Field(t => t.HeightInMeters);
    }
}

Jak widzisz wzór wystawiania na świat właściwości twojej encji jest bardzo proste. Na razie tylko dla typów wyliczeniowych musieliśmy stworzyć dodatkowe podtypy, aby określić bardziej złożone definicję tych pól.

A jak te mapowanie by wyglądało, gdybyś mieli klasę w klasie.

Do mojego projektu Domain dodajmy klasę reprezentującą opinię znawcy smoków.

DragonExpertOpinion

Nasza klasa opinij znawcy smoków ma relację ze smokiem poprzez klucz obcy.

public class DragonExpertOpinion
{
    public int Id { get; set; }
    public int DragonId { get; set; }
    public Dragon Dragon { get; set; }

    [StringLength(150), Required]
    public string Title { get; set; }

    public string Review { get; set; }
}

Poza tym ma on tytuł, jak i opinię.  Oczywiście, aby mieć do niej dostęp musimy dodać tą naszą nową klasę do kontekstu Entity Framework.

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

    }
    public DbSet<Dragon> Dragons { get; set; }

    public DbSet<DragonExpertOpinion> DragonExpertOpinion { get; set; }

}

Pozostaje nam jeszcze przygotować bazę danych na tą zmianę. Na chwilę obecną nasza baza w SQLite ma tylko tabelę smoków oraz tabelkę migracyjną wygenerowaną przez Entity Framework. Tabela sqlite_sequence to specjalna tabela SQLite, która przechowuje obecne numery ID do inkrementacji w tabelach.

SQLite baza danych dragonshop

Teraz aby zaktualizować bazę danych pozostaje nam zrobić to samo co zrobiliśmy w poprzednim wpisie. Czyli dodać nową migrację.

Add Migration w entity framework

Została teraz utworzona definicja migracyjna mówiąca o tym, że teraz powinniśmy dodać nową tabletkę do bazy.

Jak pamiętasz z poprzedniego wpisu teraz wystarczyłoby wpisać w "Package Manager Console" polecenie "Update-database".

Zróbmy to jednak dziś inaczej. Skorzystajmy z polecenia "Script-Migration". Wygeneruje nam to kod SQL, który będziemy mogli użyć bezpośrednio na naszej bazie.

BEGIN TRANSACTION;

CREATE TABLE "DragonExpertOpinion" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_DragonExpertOpinion" PRIMARY KEY AUTOINCREMENT,
    "DragonId" INTEGER NOT NULL,
    "Title" TEXT NOT NULL,
    "Review" TEXT NULL,
    CONSTRAINT "FK_DragonExpertOpinion_Dragons_DragonId" FOREIGN KEY ("DragonId") REFERENCES "Dragons" ("Id") ON DELETE CASCADE
);

CREATE INDEX "IX_DragonExpertOpinion_DragonId" ON "DragonExpertOpinion" ("DragonId");

INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20210601124712_AddDragonExpertOpinion', '5.0.6');

COMMIT;

Potem w programie DB Browser for SQLite otwieramy naszą bazę danych i wklejamy tam nasz wygenerowany skrypt.

DB Browser for SQLite

Tak do bazy dodaliśmy nową tabelkę, która jest mapowana na opinię eksperta smoków.

Tabele w bazie SQLite

W DB Browser for SQLite dodamy jedną opinię, aby sprawdzić czy będziemy mogli ten typ wysłać przez GraphQL.

Dodanie nowych opinii o smokach

Teraz wracam do naszej definicji zapytania dla smoka. Dodajmy teraz typ definicji opinii eksperta smoków dla GraphQL.

Dodanie DragonOpinionType  w Visual Studio

Wygląda on tak :

public class DragonOpinionType :
    ObjectGraphType<DragonExpertOpinion>
{
    public DragonOpinionType()
    {
        Field(t => t.Title);
        Field(t => t.Review);
    }
}

Opinię są częścią smoka na sprzedasz. Jak jednak te pole wystawić?

Czegoś tutaj brakuje. Potrzebne repozytorium

Musimy dodać do konstruktora klasy DragonType wstrzykiwanie zależności. Musimy wstrzyknąć sobie repozytorium. Tylko dobre praktyki mówią, aby mieć repozytorium per typ. Mamy repozytorium dla smoków, ale nie mamy repozytorium dla opinii. 

Tworzymy wiec je.

public class DragonExpertOpinionRepository
{
    private readonly DragonShopDbContext _dbContext;

    public DragonExpertOpinionRepository(DragonShopDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<DragonExpertOpinion> GetForDragonId(int id)
    {
        var res = _dbContext.DragonExpertOpinion.Where(k => k.DragonId == id)
            .FirstOrDefault();

        return Task.FromResult(res);
    }
}

Nowe repozytorium to nowe pola do naszego instalatora w warstwie infrastruktury.

public static class DragonShopInstaller
{
    public static IServiceCollection AddDragonPersitence(this IServiceCollection services,
        IConfiguration _config)
    {
        services.AddDbContext<DragonShopDbContext>(options =>
options.UseSqlite(_config["ConnectionStrings:DragonDB"]));
        services.AddScoped<DragonRepository>();
        services.AddScoped<DragonExpertOpinionRepository>();

        return services;
    }
}

...

Teraz możemy stworzyć dodatkowe pole dla DragonType. Zapewne się zastanawiasz jak zdobyć obecne ID smoka w kontekście zapytania. Na pomoc przychodzi właściwość context.

Context w GraphQL

Pełny kod wygląda tak:

public class DragonType : ObjectGraphType<Dragon>
{
    public DragonType(DragonExpertOpinionRepository rep)
    {
        Field<ListGraphType<DragonOpinionType>>("opinions",
            resolve: context => rep.GetForDragonId(context.Source.Id));

        Field(t => t.Id);
        Field(t => t.Name).Description("The name of the draon");
        Field(t => t.Description);
        Field(t => t.IntroducedAt).Description("When the dragon was first introduced in the catalog");
        Field(t => t.Price);
        Field(t => t.Rating).Description("The (max 5) star customer rating");
        Field<ColorDragonType>("Color", "The color of dragon");
        Field<BreathDragonType>("Breath", "The breath of dragon");

        //Field(t => t.Active);
        //Field(t => t.Age);
        //Field(t => t.CrewCapacity);
        //Field(t => t.DiameterMeters);
        //Field(t => t.DoneFirstFlight);
        //Field(t => t.DryMassKg);
        //Field(t => t.OrbitDurationYears);
        //Field(t => t.HeightInMeters);
    }
}

Uruchommy naszą aplikację zobaczymy czy zobaczmy listę opinii naszego smoka.

Na chwilą obecną jednak zobaczysz, że nasza aplikacja nie GraphQL nie działa i wyrzuca błąd MISSING_METHOD

Błąd MISSING_METHOD w GraphQL

Co nie działa ?

Na chwilę obecną GraphQL nie wie jak sobie poradzić z wstrzyknięciem zależności do "DragonExpertOpinionRepository". 

Niestety automatycznie się to nie zrobi, jakby byłby to kontroler w ASP.NET CORE. Przynajmniej na razie.

Osobiście też mam problem z pisaniem tego wpisu, bo GraphQL pisany obecnie w .NET 5 to nie ten sam GraphQL z .NET 3.2. Jak się domyślasz jest mnóstwo przykładów ze starą wersja gdzie kod troszeczkę się różni tu i tam. 

Co więc jest źle? Otóż aby każda klasa, która dziedziczy po ObjectGraphType mogła rozwiązywać takie problemy, to trzeba najpierw trzeba przekazać do bazowego konstruktora w Schema interfejs "IServiceProvider".

"IServiceProvider" nam by pozwolił wyszukiwać wszystkie implementacje po nazwie typu, którego szukamy. 

Stosowanie go jest jednak antywzorcem service locator. Na szczęście dla nas użycie jego jest gdzieś głęboko w samej bibliotece GraphQL.

public class DragonSchema : Schema
{
    private readonly DragonShopDbContext _dbContext;

    public DragonSchema(DragonShopDbContext dbContext, IServiceProvider sp) : base(sp)
    {
        _dbContext = dbContext;

        Query = new DragonQuery
           (new DragonRepository(_dbContext));
    }
}

Po tej zmianie. Nasze API działa. Możemy pobrać smoki wraz z opiniami. 

Działające API w GraphQL

To jednak nie koniec tego wpisu. Teraz gdy mamy dwie kolekcję danych, które są zależne od siebie to warto się zastanowić czy to jest wydajne.

W końcu dla listy smoków wykonujesz zapytanie SQL per każdy smok na tej liście o jego opinie. Nie brzmi to dobrze dla wydajności. 

Dodajmy Data Loader

Jak rozwiązać ten problem? Moglibyśmy napisać cały kod inaczej. Pobrać np. wszystkie opinie o smokach i przechowywać je w pamięci Cache do użycia. Moglibyśmy też napisać inaczej dostęp do encji smoków tak, aby wykonywał się automatycznie Inner Join do tabelki z opiniami.

Zobaczmy jakie rozwiązanie oferuje GraphQL.

Możemy dodać warstwę cache, która  będzie przechowywać już odpytane opinie. GraphQL już takie rozwiązanie dostarcza w postaci wzorca Data Loader.

Załóżmy, że odpytujesz opinie o smokach na bazie danego ID jak 1,2,4,5 .  Data Loader po poprawnym odczytaniu opinii zapisałby te zawartość w swojej warstwie. 

Gdyby inny użytkownik chciałby znowu przeczytać opinie na bazie ID smoka 1,2,4,5 to wtedy odesłalibyśmy dane z tej warstwy cache bez odpytania prawdziwej bazy.

Data Loader w GraphQL jak działa

Aby skorzystać, z DataLoader najpierw musimy dodać go do instalatora GraphQL.

services.AddGraphQL((o, p) =>
          {
                  var logger = p.GetRequiredService<ILogger<Startup>>();
                  o.UnhandledExceptionDelegate = ctx =>
                      logger.LogError("{Error} occurred", ctx.OriginalException.Message);

          })
.AddGraphTypes(ServiceLifetime.Scoped)
.AddDataLoader()
.AddNewtonsoftJson();

W naszym repozytorium opinii smoków potrzebna nam jest nowa metoda, która skorzysta z interfejsu ILookup.

Przy okazji jak widzisz nasza metoda pobiera listę opinii, a nie jedną opinie. Co pozwoli nam dodatkowo uniknąć odpytania bazy wiele razy per każdy smok na liście.

public class DragonExpertOpinionRepository
{
    private readonly DragonShopDbContext _dbContext;

    public DragonExpertOpinionRepository(DragonShopDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<ILookup<int, DragonExpertOpinion>> GetForDragons(IEnumerable<int> dragonIds)
    {
        var reviews = await _dbContext.DragonExpertOpinion.
            Where(pr => dragonIds.Contains(pr.DragonId)).ToListAsync();

        return reviews.ToLookup(r => r.DragonId);
    }

    public Task<List<DragonExpertOpinion>> GetForDragonId(int id)
    {
        var res = _dbContext.DragonExpertOpinion.Where(k => k.DragonId == id);


        return Task.FromResult(res.ToList());
    }
}

W naszej definicji smoka ulepszamy naszą definicję pola "opinions". Korzystamy tutaj z interfejsu IDataLoaderContextAccessor, aby mechanizm data loader był kompletny.

W LoadAsync albo pobierzemy opinie z bazy bądź odczytamy opinie, które już raz pobraliśmy.  

public class DragonType : ObjectGraphType<Dragon>
{
    public DragonType(DragonExpertOpinionRepository rep, 
IDataLoaderContextAccessor dataLoaderAccessor) { Field<ListGraphType<DragonOpinionType>>("opinions", resolve: context => { var loader = dataLoaderAccessor.Context.
GetOrAddCollectionBatchLoader<int, DragonExpertOpinion>( "GetReviewsByProductId", rep.GetForDragons); return loader.LoadAsync(context.Source.Id); }); Field(t => t.Id); Field(t => t.Name).Description("The name of the draon"); Field(t => t.Description); Field(t => t.IntroducedAt).Description("When the dragon was first introduced in the catalog"); Field(t => t.Price); Field(t => t.Rating).Description("The (max 5) star customer rating"); Field<ColorDragonType>("Color", "The color of dragon"); Field<BreathDragonType>("Breath", "The breath of dragon"); //Field(t => t.Active); //Field(t => t.Age); //Field(t => t.CrewCapacity); //Field(t => t.DiameterMeters); //Field(t => t.DoneFirstFlight); //Field(t => t.DryMassKg); //Field(t => t.OrbitDurationYears); //Field(t => t.HeightInMeters); } }

Możesz zauważyć, że ten mechanizm cache bazuje, na którego słowniku kluczem jest "int" a wartością "DragonExpertOpinion"

wczytuje asynchronicznie wartości.

Teraz nasz warstwę cache będzie przechowywać opinie na bazie ID danego Smoka. Warto zaznaczyć, że 1 smok może mieć wiele opinii.

Słownik Cache z opiniami o smokach

Możesz teraz zadać pytanie, jak udowodnić to, że ta warstwa cache działa. Dodajmy proste logowanie do konsoli do kontekstu Entity Framework.

public class DragonShopDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.LogTo(Console.WriteLine);
    }

    public DragonShopDbContext(DbContextOptions<DragonShopDbContext> options)
        : base(options)
    {

    }
    public DbSet<Dragon> Dragons { get; set; }

    public DbSet<DragonExpertOpinion> DragonExpertOpinion { get; set; }
}

Alternatywnie dodać konfiguracje logowania do appsettings.json

{
  "ConnectionStrings": {
    "DragonDB": "DataSource=./DataBase/DragonDB.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Po uruchomieniu aplikacji możesz zobaczyć, że o ile select do bazy smoków wywołuje się za każdy razem. To zapytanie do tabeli opinii wykona się już tylko raz.

Dodajmy Argumenty do wyszukiwania

Na razie jesteśmy w stanie wyszukać wszystkie smoki. Co z zapytaniami, gdy chcemy pobrać informacje tylko od pojedynczego smoka? 

Tylko jeśli chcemy odpytać pojedynczego smoka to musimy przesłać jego ID. Teraz pytanie do Ciebie gdzie to zrobimy. W definicji : Schema, Query, czy naszych typów zwracanych przez GraphQL.

Gdzie dodamy nowe zapytanie do GraphQL

Oczywiście na logikę skoro dochodzi do nas nowe zapytanie z argumentami to musimy je dodać do definicji Query.

Najpierw jednak musimy dodać nową metodę do repozytorium. Oto metoda GetOne.

public class DragonRepository
{
    private readonly DragonShopDbContext _dbContext;

    public DragonRepository(DragonShopDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<Dragon> GetOne(int id)
    {
        return _dbContext.Dragons.SingleAsync(p => p.Id == id);
    }

    public Task<List<Dragon>> GetAll()
    {
        return _dbContext.Dragons.ToListAsync();
    }
}

Dla zapytania "dragon" chcemy odczytać "id". Nasze "id" w zapytaniu nie jest opcjonalne i mówi o tym typ "NonNullGraphType". Jak się okazuje dla "id" jest już gotowy typ "IdGraphType"

Kod w C# wygląda następująco.

public class DragonQuery : ObjectGraphType
{
    public DragonQuery(DragonRepository dragonRepository)
    {

        Field<DragonType>(
            "dragon",
            arguments: new QueryArguments(new QueryArgument<NonNullGraphType<IdGraphType>>
            { Name = "id" }),
            resolve: context =>
            {
                var id = context.GetArgument<int>("id");
                return dragonRepository.GetOne(id);
            }
        );

        Field<ListGraphType<DragonType>>(
            "dragons",
            resolve: context => dragonRepository.GetAll()

        );
    }
}

Aby odczytać argument korzystam z lokalnego kontekstu i metody GetArgument. Nasz playground GraphQL wykrywa już nasze nowe zapytanie.

GraphQL wykrywa zapytanie dragon

Jak wygląda składnia JSON zapytania? Argumenty przekazuje w nawisach zwykłych. Nawiasach klamrowych mówię jakie pola chce pobrać z dostępnych.

{
  dragon(id:1){
    id
  }
}

Dodajmy Autoryzację z ASP.NET CORE

GraphQL korzysta ze słownika, jeśli chodzi o Autoryzacje użytkowników. Stwórzmy więc swoją klasę, która będzie dziedziczyć po tym słowniku. Wygląda to trochę głupio, bo nie będziemy z mechanizmu Dictionary i tak korzystać.

public class GraphQLUserContext : Dictionary<string, object>
{
    public ClaimsPrincipal User { get; set; }
}

Do niego dodajmy właściwość ClaimsPrincipal, która symbolizuje wszystkie informację o użytkowniku, które są przesyłane przez HTTP w ASP.NET CORE.

Do instalacji GrapQL dodajemy obsługę naszego użytkownika.  Dla każdego zapytania HTTP pobierzemy z kontekstu użytkownika, a on będzie w naszej klasie GrapQLUserContext.

services.AddGraphQL((o, p) =>
          {
  var logger = p.GetRequiredService<ILogger<Startup>>();
  o.UnhandledExceptionDelegate = ctx =>
      logger.LogError("{Error} occurred", ctx.OriginalException.Message);

          })

.AddGraphTypes(ServiceLifetime.Scoped)
.AddUserContextBuilder(httpContext => new GraphQLUserContext { User = httpContext.User })
.AddDataLoader()
.AddNewtonsoftJson();

Teraz gdy masz użytkownika dla każdego pola możesz dodać sprawdzenie jego tożsamości.

Field<ListGraphType<DragonOpinionType>>("opinions",
resolve: context =>
{
    var user = (GraphQLUserContext)context.UserContext;

    if (user.User.Identity.Name == "Cez") { }


    var loader =
       dataLoaderAccessor.Context.GetOrAddCollectionBatchLoader<int, DragonExpertOpinion>(
           "GetReviewsByProductId", rep.GetForDragons);
    return loader.LoadAsync(context.Source.Id);

});

Możesz też to zrobić na podstawie całego zapytania

public DragonQuery(DragonRepository dragonRepository)
{

    Field<DragonType>(
        "dragon",
        arguments: new QueryArguments(new QueryArgument<NonNullGraphType<IdGraphType>>
        { Name = "id" }),
        resolve: context =>
        {
            var user = (GraphQLUserContext)context.UserContext;

            if (user.User.Identity.Name == "Cez") { }

            var id = context.GetArgument<int>("id");
            return dragonRepository.GetOne(id);
        }

Jedyny problem na razie wynika z tego, że nie mamy odpowiedniego klienta, który by mógł wysłać odpowiednie dane użytkownika. Nie możemy więc na razie przetestować logiki autoryzacyjnej, która została przez nas napisana.

Pora więc przejść dalej i napisać drugą aplikację w .NET, która będzie odpytywać nasze GraphQL API.