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.
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.
Teraz aby zaktualizować bazę danych pozostaje nam zrobić to samo co zrobiliśmy w poprzednim wpisie. Czyli dodać nową migrację.
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.
Tak do bazy dodaliśmy nową tabelkę, która jest mapowana na opinię eksperta smoków.
W DB Browser for SQLite dodamy jedną opinię, aby sprawdzić czy będziemy mogli ten typ wysłać przez GraphQL.
Teraz wracam do naszej definicji zapytania dla smoka. Dodajmy teraz typ definicji opinii eksperta smoków dla GraphQL.
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ć?
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.
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
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.
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.
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.
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.
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.
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.