I have recently created a project on Github that demonstrates how to build a GraphQL API through the use of a number of examples. I created the project in order to help serve as a guide for .NET developers that are new to GraphQL and would like to learn how to get started. As a final step to this project, I also demonstrate how to create the required infrastructure in Microsoft Azure to host the GraphQL API.
Key Takeaways
- Project Overview
- Learn how to build a .NET 6 GraphQL API from scratch
- Learn how to define GraphQL queries, and mutations
- Learn how to provide validation and error handling
- Add EntityFramework support both in-memory and SQLite database
- Add support for query projection, filtering, sorting, and paging
- Provision Azure App Service using Azure CLI, Azure, Powershell, and Azure Bicep
Project Overview
- This project explores how to build and deploy a GraphQL API in a number of easy to follow examples.
- Each example introduces a new concept that is clear, small, and easy to understand
- All examples are built using .NET 6 (LTS) and ChilliCream Hot Chocolate (Open-Source GraphQL Server for Microsoft .NET platform)
- The GraphQL API is deployed to Azure App Service
- The project demonstrates how to provision an Azure App Service using Azure CLI, Azure Powershell, and Azure Bicep
All examples are based on a fictitious company called MyView, that provides a service to view games and game ratings. It also allows reviewers to post and delete game reviews. Therefore, in the examples that follow, I demonstrate how to build a GraphQL API that provides the following capabilities:
- List games
- List games with associated reviews
- Find a game by id
- Post a game review
- Delete a game review
The details of the provided examples are as follows:
- 📘 Example 1
- Create empty API project
- Add GraphQL Server support through the use of the ChilliCream Hot Chocolate nuget package
- Create a games query
- Create a games mutation
- Add metadata to support schema documentation
- 📘 Example 2
- Add global error handling
- Add input validation
- 📘 Example 3
- Add EntityFramework support with an in-memory database
- Add support for query filtering, sorting, and paging
- Add projection support
- Add query type extensions to allow splitting queries into multiple classes
- 📘 Example 4
- Change project to use SQlite instead of an in-memory database
Lastly, I provide the instructions on how to provision an Azure App Service and deploy the GraphQL API to it. The details are as follows:
- Provision Azure App Service using Azure CLI
- Provision Azure App Service using Azure Powershell
- Provision Azure App Service using Azure Bicep
- Deploy GraphQL API
📘 Find all the infrastructure as code here
Required Setup and Tools
It is recommended that the following tools and frameworks are installed before trying to run the examples:
All code in this example is developed with C# 10 using the latest cross-platform .NET 6 framework.
See the .NET 6 SDK official website for details on how to download and setup for your development environment.
Find more information on Visual Studio Code with relevant C# and .NET extensions.
Everything you need to install and configure your windows, linux, and macos environment
Provides Bicep language support
jq is a lightweight and flexible command-line JSON processor
A package of all the Visual Studio Code extensions that you will need to work with Azure
Tools for developing and running commands of the Azure CLI
Example 1
Example 1 demonstrates how to get started in terms of creating and running a GraphQL Server.
📘 The full example can be found on Github.
Step 1 - Create project
# create empty solution
dotnet new sln --name MyView
# create basic webapi project using minimal api's
dotnet new webapi --no-https --no-openapi --framework net6.0 --use-minimal-apis --name MyView.Api
# add webapi project to solution
dotnet sln ./MyView.sln add ./MyView.Api
Step 2 - Add GraphQL Capability to Web API
In this section, we turn the web application into a GraphQL API by installing the Chillicream Hot Chocolate GraphQL nuget package called HotChocolate.AspNetCore
. This package contains the Hot Chocolate GraphQL query execution engine and query validation.
# From the /MyView.Api project folder, type the following commands:
# Add HotChocolate packages
dotnet add package HotChocolate.AspNetCore --version 12.11.1
Step 3 - Add Types
We are building a GraphQL API to allow querying and managing game reviews. Therefore, to get started we need to create a Game and GameReview type. A few things to note about the types we create:
- I append the suffix "Dto" to indicate that the type is a Data Transfer Object. I use this to make it explicitly clear as to the intent of the type.
- The
GraphQLName
attribute is used to rename the type for public consumption. The types will be exposed asGame
andGameReview
as opposed toGameDto
andGameReviewDto
- The
GraphQLDescription
attribute is used to provide a description of the type that is used by the GraphQL server to provide more detailed schema information - The types are defined as a record type, but can be declared as classes. I have chosen to use the record type as it allows me to define data contracts that are immutable and support value based equality comparison (should I require it).
[GraphQLDescription("Basic game information")]
[GraphQLName("Game")]
public sealed record GameDto
{
public GameDto()
{
Reviews = new List<GameReviewDto>();
}
[GraphQLDescription("A unique game identifier")]
public Guid GameId { get; set; }
[GraphQLDescription("A brief description of game")]
public string Summary { get; set; } = string.Empty;
[GraphQLDescription("The name of the game")]
public string Title { get; set; } = string.Empty;
[GraphQLDescription("The date that game was released")]
public DateTime ReleasedOn { get; set; }
[GraphQLDescription("A list of game reviews")]
public ICollection<GameReviewDto> Reviews { get; set; } = new List<GameReviewDto>();
}
// GameReviewDto.cs
[GraphQLDescription("Game review information")]
[GraphQLName("GameReview")]
public sealed record GameReviewDto
{
[GraphQLDescription("Game identifier")]
public Guid GameId { get; set; }
[GraphQLDescription("Reviewer identifier")]
public Guid ReviewerId { get; set; }
[GraphQLDescription("Game rating")]
public int Rating { get; set; }
[GraphQLDescription("A brief description of game experience")]
public string Summary { get; set; } = string.Empty;
}
Step 4 - Create First Query
One or more queries must be defined in order to support querying data.
For our first example we will create a query to support retrieving of game related (games, reviews) data. A few things to note are as follows:
- At this stage we do not not have a database configured and will instead use in-memory data to demonstrate fetching of data.
- I use the
GraphQLDescription
attribute to provide GraphQL schema documentation
// In this example, we use GameData (in-memory list) to provide sample dummy game related data
[GraphQLDescription("Query games")]
public sealed class GamesQuery
{
[GraphQLDescription("Get list of games")]
public IEnumerable<GameDto> GetGames() => GameData.Games;
[GraphQLDescription("Find game by id")]
public GameDto? FindGameById(Guid gameId) =>
GameData.Games.FirstOrDefault(game => game.GameId == gameId);
}
Step 5 - Create First Mutation
We will add 2 operations. One to create, and one to remove a game review. A few things to note are as follows:
- At this stage we do not not have a database configured and will instead use in-memory data to demonstrate saving of data.
- The
GraphQLDescription
attribute is used to provide GraphQL schema documentation
[GraphQLDescription("Manage games")]
public sealed class GamesMutation
{
[GraphQLDescription("Submit a game review")]
public GameReviewDto SubmitGameReview(GameReviewDto gameReview)
{
var game = GameData
.Games
.FirstOrDefault(game => game.GameId == gameReview.GameId)
?? throw new Exception("Game not found");
var gameReviewFromDb = game.Reviews.FirstOrDefault(review =>
review.GameId == gameReview.GameId && review.ReviewerId == gameReview.ReviewerId);
if (gameReviewFromDb is null)
{
game.Reviews.Add(gameReview);
}
else
{
gameReviewFromDb.Rating = gameReview.Rating;
gameReviewFromDb.Summary = gameReview.Summary;
}
return gameReview;
}
[GraphQLDescription("Remove a game review")]
public GameReviewDto DeleteGameReview(Guid gameId, Guid reviewerId)
{
var game = GameData
.Games
.FirstOrDefault(game => game.GameId == gameId)
?? throw new Exception("Game not found");
var gameReviewFromDb = game
.Reviews
.FirstOrDefault(review => review.GameId == gameId && review.ReviewerId == reviewerId)
?? throw new Exception("Game review not found");
game.Reviews.Remove(gameReviewFromDb);
return gameReviewFromDb;
}
}
Step 6 - Configure API with GraphQL services
We need to configure the API to use the ChilliCream Hotchocolate GraphQL services and middleware.
builder
.Services
.AddGraphQLServer() // Adds a GraphQL server configuration to the DI
.AddMutationType<GamesMutation>() // Add GraphQL root mutation type
.AddQueryType<GamesQuery>() // Add GraphQL root query type
.ModifyRequestOptions(options =>
{
// allow exceptions to be included in response when in development
options.IncludeExceptionDetails = builder.Environment.IsDevelopment();
});
Step 7 - Map GraphQL Endpoint
app.MapGraphQL("/"); // Adds a GraphQL endpoint to the endpoint configurations
Step 8 - Run GraphQL API
Run the Web API project by typing the following command:
# From the /MyView.Api project folder, type the following commands:
dotnet run
Schema Information
Selecting the Browse Schema options allows one to view the schema information for queries, mutations, and objects as can be seen by the following screen shots.
Objects Schema
Queries Schema
Mutations Schema
Write GraphQL Queries
# List all games and associated reviews:
query listGames {
games {
gameId
title
releasedOn
summary
reviews {
reviewerId
rating
summary
}
}
}
# Find a game (with reviews) by game id
# Add the following JSON snippet to the variables section:
{
"gameId": "8f7e254f-a6ce-4f13-a44c-8f102a17f2f5"
}
# Write query to find a game (with reviews)
query findGameById ($gameId: UUID!) {
findGameById (gameId: $gameId) {
gameId
title
reviews {
reviewerId
rating
summary
}
}
}
Write Mutations
# Submit a game review
# Define the following JSON in the 'Variables' section:
{
"gameReview": {
"gameId": "8f7e254f-a6ce-4f13-a44c-8f102a17f2f5",
"reviewerId": "025fefe4-e2de-4765-9af3-f5dcd6d47458",
"rating": 75,
"summary": "Enim quidem enim. Eius aut velit voluptas."
},
}
# Write a mutation to submit a game review
mutation submitGameReview($gameReview: GameReviewInput!) {
submitGameReview(gameReview: $gameReview) {
gameId
reviewerId
rating
summary
}
}
# Write a mutation to DELETE a game review
# Define the following JSON in the 'variables' section:
{
"gameId": "8f7e254f-a6ce-4f13-a44c-8f102a17f2f5",
"reviewerId": "025fefe4-e2de-4765-9af3-f5dcd6d47458"
}
# Write mutation to delete game review
mutation deleteGameReview($gameId: UUID!, $reviewerId: UUID!) {
deleteGameReview(gameId: $gameId, reviewerId: $reviewerId) {
gameId
reviewerId
rating
summary
}
}
Example 2
Example 2 demonstrates the following concepts:
- Implement input validations (like validating a review that is submitted)
- Implement global error handling
📘 The full example can be found on Github.
Implement Input Validation
Currently, we have no validation in place when submitting a GameReview
. In this section, we are going to provide input validation through the use of the [Fluent Validation] nuget package.
Step 1 - Add Fluent Validation packages
dotnet add package FluentValidation.AspNetCore --version 11.1.1
dotnet add package FluentValidation.DependencyInjectionExtensions --version 11.1.0
Step 2 - Create Validator
We need to define a class that will handle validation for the GameReview
type. To do this, we create a GameReviewValidator
as follows:
public sealed class GameReviewValidator : AbstractValidator<GameReviewDto>
{
public GameReviewValidator()
{
RuleFor(e => e.GameId)
.Must(gameId => GameData.Games.Any(game => game.GameId == gameId))
.WithMessage(e => $"A game having id '{e.GameId}' does not exist");
RuleFor(e => e.Rating)
.LessThanOrEqualTo(100)
.GreaterThanOrEqualTo(0);
RuleFor(e => e.Summary)
.NotNull()
.NotEmpty()
.MinimumLength(20)
.MaximumLength(500);
}
}
Step 3 - Use GameReviewValidator
The
GameReviewValidator
is used in theGamesMutation
class to validate theGameReviewDto
.We use constructor dependency injection to provide the
GameReviewValidator
The code
_validator.ValidateAndThrow(gameReview);
will execute the validation rules defined forGameReviewDto
and throw a validation exception if there are any validation failures.
[GraphQLDescription("Manage games")]
public sealed class GamesMutation
{
private readonly AbstractValidator<GameReviewDto> _validator;
public GamesMutation(AbstractValidator<GameReviewDto> validator)
{
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
}
[GraphQLDescription("Submit a game review")]
public GameReviewDto SubmitGameReview(GameReviewDto gameReview)
{
// use fluent validator
_validator.ValidateAndThrow(gameReview);
.
.
.
}
}
Step 4 - Add Validation Service
// configure fluent validation for GameReviewDto
builder.Services.AddTransient<AbstractValidator<GameReviewDto>, GameReviewValidator>();
Configure Global Error Handling
We are throwing exceptions in a number of areas. Those exceptions will be allowed to bubble all the way to the client if allowed to. The following list highlights the areas where we are throwing exceptions along with the GraphQL response resultiing from the exception:
- Validation
public GameReviewDto SubmitGameReview(GameReviewDto gameReview)
{
// use fluent validator
_validator.ValidateAndThrow(gameReview);
.
.
.
}
{
"errors": [
{
"message": "Unexpected Execution Error",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"submitGameReview"
],
"extensions": {
"message": "Validation failed: \r\n -- Rating: 'Rating' must be greater than or equal to '0'. Severity: Error\r\n -- Summary: 'Summary' must not be empty. Severity: Error\r\n -- Summary: The length of 'Summary' must be at least 20 characters. You entered 0 characters. Severity: Error",
"stackTrace": " at FluentValidation.AbstractValidator`..."
}
}
]
}
- Delete Game
public GameReviewDto DeleteGameReview(Guid gameId, Guid reviewerId)
{
var game = GameData
.Games
.FirstOrDefault(game => game.GameId == gameId)
?? throw GameNotFoundException.WithGameId(gameId);
.
.
.
}
{
"errors": [
{
"message": "Unexpected Execution Error",
"locations": [
{
"line": 11,
"column": 3
}
],
"path": [
"deleteGameReview"
],
"extensions": {
"message": "Exception of type 'MyView.Api.Games.GameNotFoundException' was thrown.",
"stackTrace": " at MyView.Api.Games.GamesMutation.DeleteGameReview ..."
}
}
]
}
Step 1 - Create Custom Error Filter
We create a new class called ServerErrorFilter
that inherits from the IErrorFilter
interface as follows:
public sealed class ServerErrorFilter : IErrorFilter
{
private readonly ILogger _logger;
private readonly IWebHostEnvironment _environment;
public ServerErrorFilter(ILogger logger, IWebHostEnvironment environment)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
}
public IError OnError(IError error)
{
_logger.LogError(error.Exception, error.Message);
if (_environment.IsDevelopment())
return error;
return ErrorBuilder
.New()
.SetMessage("An unexpected server fault occurred")
.SetCode(ServerErrorCode.ServerFault)
.SetPath(error.Path)
.Build();
}
}
Step 2 - Add ValidationException Handler
public sealed class ServerErrorFilter : IErrorFilter
{
.
.
.
public IError OnError(IError error)
{
if (error.Exception is ValidationException validationException)
{
_logger.LogError(validationException, "There is a validation error");
var errorBuilder = ErrorBuilder
.New()
.SetMessage("There is a validation error")
.SetCode(ServerErrorCode.BadUserInput)
.SetPath(error.Path);
foreach (var validationFailure in validationException.Errors)
{
errorBuilder.SetExtension(
$"{ServerErrorCode.BadUserInput}_{validationFailure.PropertyName.ToUpper()}",
validationFailure.ErrorMessage);
}
return errorBuilder.Build();
}
.
.
.
}
}
Step 3 - Add GameNotFoundException Handler
public sealed class ServerErrorFilter : IErrorFilter
{
.
.
.
public IError OnError(IError error)
{
.
.
.
if (error.Exception is GameNotFoundException gameNotFoundException)
{
_logger.LogError(gameNotFoundException, "Game not found");
return ErrorBuilder
.New()
.SetMessage($"A game having id '{gameNotFoundException.GameId} could not found")
.SetCode(ServerErrorCode.ResourceNotFound)
.SetPath(error.Path)
.SetExtension($"{ServerErrorCode.ResourceNotFound}_GAME_ID", gameNotFoundException.GameId)
.Build();
}
.
.
.
}
}
Step 4 - Add GameReviewNotFoundException Handler
public sealed class ServerErrorFilter : IErrorFilter
{
.
.
.
public IError OnError(IError error)
{
.
.
.
if (error.Exception is GameReviewNotFoundException gameReviewNotFoundException)
{
_logger.LogError(gameReviewNotFoundException, "Game review not found");
return ErrorBuilder
.New()
.SetMessage($"A game review having game id '{gameReviewNotFoundException.GameId}' and reviewer id '{gameReviewNotFoundException.ReviewerId}' could not found")
.SetCode(ServerErrorCode.ResourceNotFound)
.SetPath(error.Path)
.SetExtension($"{ServerErrorCode.ResourceNotFound}_GAME_ID", gameReviewNotFoundException.GameId)
.SetExtension($"{ServerErrorCode.ResourceNotFound}_REVIEWER_ID", gameReviewNotFoundException.ReviewerId)
.Build();
}
.
.
.
}
}
Step 5 - Configure GraphQL Service to support Error Filter
builder
.Services
.AddGraphQLServer()
// Add global error handling
.AddErrorFilter(provider =>
{
return new ServerErrorFilter(
provider.GetRequiredService<ILogger<ServerErrorFilter>>(),
builder.Environment);
})
.
.
.
.ModifyRequestOptions(options =>
{
options.IncludeExceptionDetails = builder.Environment.IsDevelopment();
});
Step 6 - Test Error Handling
- Validation
{
"errors": [
{
"message": "There is a validation error",
"path": [
"submitGameReview"
],
"extensions": {
"code": "BAD_USER_INPUT",
"BAD_USER_INPUT_RATING": "'Rating' must be greater than or equal to '0'.",
"BAD_USER_INPUT_SUMMARY": "The length of 'Summary' must be at least 20 characters. You entered 0 characters."
}
}
]
}
- GameNotFoundException
{
"errors": [
{
"message": "A game having id '8f7e254f-a6ce-4f13-a44c-8f102a17f2f4 could not found",
"path": [
"deleteGameReview"
],
"extensions": {
"code": "RESOURCE_NOT_FOUND",
"RESOURCE_NOT_FOUND_GAME_ID": "8f7e254f-a6ce-4f13-a44c-8f102a17f2f4"
}
}
]
}
- GameReviewNotFoundException
{
"errors": [
{
"message": "A game review having game id '8f7e254f-a6ce-4f13-a44c-8f102a17f2f5' and reviewer id '8b019864-4af2-4606-88b5-13e5eb62ff4e' could not found",
"path": [
"deleteGameReview"
],
"extensions": {
"code": "RESOURCE_NOT_FOUND",
"RESOURCE_NOT_FOUND_GAME_ID": "8f7e254f-a6ce-4f13-a44c-8f102a17f2f5",
"RESOURCE_NOT_FOUND_REVIEWER_ID": "8b019864-4af2-4606-88b5-13e5eb62ff4e"
}
}
]
}
Example 3
For Example 3, we extend Example 2 to cover the following topics:
- Add EntityFramework support with in-memory database
- Add support for query projection, filtering, sorting, and paging
- Add query type extensions to allow splitting queries into multiple classes
- Add database seeding (seed with fake data)
Add EntityFramework Support
- Add required nuget packages
- Create the following entities that will be used to help represent the data stored in our database
Game
GameReview
Reviewer
- Create a context that will serve as our Repository/UnitOfWork called
AppDbContext
- Configure data services
Add EntityFramework Packages
# add required EntityFramework packages
dotnet add package Microsoft.EntityFrameworkCore.Design --version 6.0.6
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 6.0.6
# add HotChocolate package providing EntityFramework support
dotnet add package HotChocolate.Data.EntityFramework --version 12.11.1
Define Entities
public class Game
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public DateTime ReleasedOn { get; set; }
public ICollection<GameReview> Reviews { get; set; } = new HashSet<GameReview>();
}
public sealed class GameReview
{
public Guid GameId { get; set; }
public Game? Game { get; set; }
public Guid ReviewerId { get; set; }
public Reviewer? Reviewer { get; set; }
public int Rating { get; set; }
public string Summary { get; set; } = string.Empty;
public DateTime ReviewedOn { get; set; }
}
public class Reviewer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Picture { get; set; } = string.Empty;
public ICollection<GameReview> GameReviews { get; set; } = new HashSet<GameReview>();
}
Create AppDbContext
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Game> Games { get; set; } = null!;
public DbSet<GameReview> Reviews { get; set; } = null!;
public DbSet<Reviewer> Reviewers { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Game>(game =>
{
...
});
modelBuilder.Entity<Reviewer>(reviewer =>
{
...
});
modelBuilder.Entity<GameReview>(gameReview =>
{
...
});
base.OnModelCreating(modelBuilder);
}
}
Configure Data Services
var builder = WebApplication.CreateBuilder(args);
// configure in-memory database
builder
.Services
.AddDbContextFactory<AppDbContext>(options =>
{
options.UseInMemoryDatabase("myview");
options.EnableDetailedErrors(builder.Environment.IsDevelopment());
options.EnableSensitiveDataLogging(builder.Environment.IsDevelopment());
});
builder
.Services
.AddScoped<AppDbContext>(provider => provider
.GetRequiredService<IDbContextFactory<AppDbContext>>()
.CreateDbContext());
Update GamesQuery
[GraphQLDescription("Get list of games")]
public IQueryable<GameDto> GetGames([Service] AppDbContext context)
{
return context
.Games
.AsNoTracking()
.TagWith($"{nameof(GamesQuery)}::{nameof(GetGames)}")
.OrderByDescending(game => game.ReleasedOn)
.Include(game => game.Reviews)
.Select(game => new GameDto
{
GameId = game.Id,
Reviews = game.Reviews.Select(review => new GameReviewDto
{
GameId = review.GameId,
Rating = review.Rating,
ReviewerId = review.ReviewerId,
Summary = review.Summary
}),
ReleasedOn = game.ReleasedOn,
Summary = game.Summary,
Title = game.Title
});
}
[GraphQLDescription("Find game by id")]
public async Task<GameDto?> FindGameById([Service] AppDbContext context, Guid gameId)
{
var game = await context
.Games
.AsNoTracking()
.TagWith($"{nameof(GamesQuery)}::{nameof(FindGameById)}")
.Include(game => game.Reviews)
.FirstOrDefaultAsync(game => game.Id == gameId);
if (game is null) return null;
return new GameDto
{
GameId = game.Id,
Reviews = game.Reviews.Select(review => new GameReviewDto
{
GameId = review.GameId,
Rating = review.Rating,
ReviewerId = review.ReviewerId,
Summary = review.Summary
}),
ReleasedOn = game.ReleasedOn,
Summary = game.Summary,
Title = game.Title
};
}
Update GamesMutation
[GraphQLDescription("Submit a game review")]
public async Task<GameReviewDto> SubmitGameReview(
[Service] AppDbContext context,
GameReviewDto gameReview)
{
// use fluent validator
await _validator.ValidateAndThrowAsync(gameReview);
var game = await context
.Games
.FirstOrDefaultAsync(game => game.Id == gameReview.GameId)
?? throw GameNotFoundException.WithGameId(gameReview.GameId);
var reviewer = await context
.Reviewers
.FirstOrDefaultAsync(reviewer => reviewer.Id == gameReview.ReviewerId)
?? throw ReviewerNotFoundException.WithReviewerId(gameReview.ReviewerId);
var gameReviewFromDb = await context
.Reviews
.FirstOrDefaultAsync(review =>
review.GameId == gameReview.GameId
&& review.ReviewerId == gameReview.ReviewerId);
if (gameReviewFromDb is null)
{
context.Reviews.Add(new GameReview
{
GameId = gameReview.GameId,
Rating = gameReview.Rating,
ReviewedOn = DateTime.UtcNow,
ReviewerId = gameReview.ReviewerId,
Summary = gameReview.Summary
});
}
else
{
gameReviewFromDb.Rating = gameReview.Rating;
gameReviewFromDb.Summary = gameReview.Summary;
}
await context.SaveChangesAsync();
return gameReview;
}
[GraphQLDescription("Remove a game review")]
public async Task<GameReviewDto> DeleteGameReview(
[Service] AppDbContext context,
Guid gameId,
Guid reviewerId)
{
var gameReviewFromDb = await context
.Reviews
.FirstOrDefaultAsync(review => review.GameId == gameId && review.ReviewerId == reviewerId)
?? throw GameReviewNotFoundException.WithGameReviewId(gameId, reviewerId);
context.Reviews.Remove(gameReviewFromDb);
return new GameReviewDto
{
GameId = gameReviewFromDb.GameId,
Rating = gameReviewFromDb.Rating,
ReviewerId = gameReviewFromDb.ReviewerId,
Summary = gameReviewFromDb.Summary
};
}
Add Query Projection, Paging, Filtering and Sorting Support
There are 2 steps required to enable projections, paging, filtering and sorting.
Step 1 - Add Attributes To Queries
Add the following attributes to the method performing queries. The ordering of the attributes is important and should be defined in the following order
UsePaging
UseProjection
UseFiltering
UseSorting
[GraphQLDescription("Get list of games")]
[UsePaging]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<GameDto> GetGames([Service] AppDbContext context)
{
...
}
[GraphQLDescription("Find game by id")]
[UseProjection]
public async Task<GameDto?> FindGameById([Service] AppDbContext context, Guid gameId)
{
...
}
Step 2 - Configure GraphQL Service
builder
.Services
.AddGraphQLServer()
.
.
.
// The .AddTypeExtension allows having queries defined in multiple files
// whilst still having a single root query
.AddQueryType() // Add GraphQL root query type
.AddTypeExtension<GamesQuery>()
.AddTypeExtension<ReviewerQuery>()
// Add Projection, Filtering and Sorting support. The ordering matters.
.AddProjections()
.AddFiltering()
.AddSorting()
...
);
Add Database Seeding
Define Seeder class to generate fake data
internal static class Seeder
{
public static async Task SeedDatabase(WebApplication app)
{
await using (var serviceScope = app.Services.CreateAsyncScope())
{
var context = serviceScope
.ServiceProvider
.GetRequiredService<AppDbContext>();
await context.Database.EnsureCreatedAsync();
if (!await context.Reviewers.AnyAsync())
{
var reviewers = JsonSerializer.Deserialize<IEnumerable<Reviewer>>(
_reviewersText,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
context.AddRange(reviewers);
}
if (!await context.Games.AnyAsync())
{
var games = JsonSerializer.Deserialize<IEnumerable<Game>>(
_gamesText,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
context.Games.AddRange(games);
}
await context.SaveChangesAsync();
}
}
private static readonly string _gamesText = @"
[
.
.
.
]";
private static readonly string _reviewersText = @"
[
.
.
.
]";
}
Configure when to Seed database
// Program.cs
app.MapGraphQL("/");
// seed database after all middleware is setup
await Seeder.SeedDatabase(app);
Example 4
For Example 4, we extend Example 3 to use a SQLite database.
Configure SQLite
Add Packages
# add required EntityFramework packages
dotnet add package Microsoft.EntityFrameworkCore.Design --version 6.0.6
dotnet add package Microsoft.EntityFrameworkCore.SQLite --version 6.0.6
# add HotChocolate package providing EntityFramework support
dotnet add package HotChocolate.Data.EntityFramework --version 12.11.1
Configure AppDbContext
Update the OnModelCreating
method to provide all the required entity-to-table mappings and relationships.
public sealed class AppDbContext : DbContext
{
.
.
.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// configure Game
modelBuilder.Entity<Game>(game =>
{
// configure table
game.ToTable(nameof(Game).ToLower());
// configure properties
game.Property(e => e.Id).HasColumnName("id");
game.Property(e => e.ReleasedOn).HasColumnName("released_on").IsRequired();
game.Property(e => e.Summary).HasColumnName("summary").IsRequired();
game.Property(e => e.Title).HasColumnName("title").IsRequired();
// configure primary key
game.HasKey(e => e.Id).HasName("pk_game_id");
// configure game relationship
game.HasMany(e => e.Reviews).WithOne(e => e.Game);
});
// configure Reviewer
modelBuilder.Entity<Reviewer>(reviewer =>
{
// configure table
reviewer.ToTable(nameof(Reviewer).ToLower());
// configure properties
reviewer.Property(e => e.Id).HasColumnName("id");
reviewer.Property(e => e.Email).HasColumnName("email").IsRequired();
reviewer.Property(e => e.Name).HasColumnName("name").IsRequired();
reviewer.Property(e => e.Picture).HasColumnName("picture").IsRequired();
reviewer.Property(e => e.Username).HasColumnName("username").IsRequired();
// configure primary key
reviewer.HasKey(e => e.Id).HasName("pk_reviewer_id");
// configure reviewer relationship
reviewer.HasMany(e => e.GameReviews).WithOne(e => e.Reviewer);
});
// configure GameReview
modelBuilder.Entity<GameReview>(gameReview =>
{
// configure table
gameReview.ToTable("game_review");
// configure properties
gameReview.Property(e => e.GameId).HasColumnName("game_id").IsRequired();
gameReview.Property(e => e.ReviewerId).HasColumnName("reviewer_id").IsRequired();
gameReview.Property(e => e.Rating).HasColumnName("rating").IsRequired();
gameReview.Property(e => e.ReviewedOn).HasColumnName("reviewed_on").IsRequired();
gameReview.Property(e => e.Summary).HasColumnName("summary").HasDefaultValue("");
// configure primary key
gameReview
.HasKey(e => new { e.GameId, e.ReviewerId })
.HasName("pk_gamereview_gameidreviewerid");
// configure game relationship
gameReview
.HasOne(e => e.Game)
.WithMany(e => e.Reviews)
.HasConstraintName("fk_gamereview_gameid");
gameReview
.HasIndex(e => e.GameId)
.HasDatabaseName("ix_gamereview_gameid");
// configure reviewer relationship
gameReview
.HasOne(e => e.Reviewer)
.WithMany(e => e.GameReviews)
.HasConstraintName("fk_gamereview_reviewerid");
gameReview
.HasIndex(e => e.ReviewerId)
.HasDatabaseName("ix_gamereview_reviewerid");
});
base.OnModelCreating(modelBuilder);
}
}
Configure Data Services
builder
.Services
.AddDbContextFactory<AppDbContext>(options =>
{
options.UseSqlite("Data Source=myview.db");
options.EnableDetailedErrors(builder.Environment.IsDevelopment());
options.EnableSensitiveDataLogging(builder.Environment.IsDevelopment());
});
builder
.Services
.AddScoped<AppDbContext>(provider => provider
.GetRequiredService<IDbContextFactory<AppDbContext>>()
.CreateDbContext());
Create Database Migrations
Before running the application, we need to create the database migrations in order to create/update the SQLite database. After the migrations and database update are complete, a SQLite database called myview.db
will be created in the root of the project folder.
# navigate to API project folder
cd ./MyView.Api
# create the database migrations
dotnet ef migrations add "CreateInitialDatabaseSchema"
Create and Update SQLite Database
# run the migrations to create database
dotnet ef database update
Deploy GraphQL API
In this section, we will cover the following primary topics:
- Provision Azure App Service
- Deploy GraphQL API to Azure App Service
Provision Azure App Service
We will be deploying the 'MyView API' into [Azure App Service]. However, before we can deploy our API, we first need to provision our Azure App Service. This section demonstrates how to provision an Azure App Service using 3 different techniques and are listed as follows:
- Azure CLI
- Azure Powershell
- Azure Bicep
Regardless of the chosen technique, there are 3 general steps that need to be completed in order to provision the Azure App Service.
- Create a resource group
- Create an App Service Plan
- Create an App Service
Once all the required resources are created, we will be ready to deploy the 'MyView API' to Azure.
Create Azure App Service Using Azure CLI
All the commands that are required to create the Azure App Service using the Azure CLI can be found in the iac/azcli/deploy.azcli
file that is part of the example github repository
$location = "australiaeast"
# STEP 1 - create resource group
$rgName = "myview-rg"
az group create `
--name $rgName `
--location $location
# STEP 2 - create appservice plan
$aspName = "myview-asp"
$appSku = "F1"
az appservice plan create `
--name $aspName `
--resource-group $rgName `
--sku $appSku `
--is-linux
# STEP 3 - create webapp
$appName = "myview-webapp-api"
$webapp = az webapp create `
--name $appName `
--resource-group $rgName `
--plan $aspName `
--runtime '"DOTNETCORE|6.0"'
# STEP 4 - cleanup
az group delete --resource-group $rgName -y
Create Azure App Service Using Azure Powershell
All the commands that are required to create the Azure App Service using the Azure Powershell can be found in the /azpwsh/deploy.azcli
file that is part of the example github repository.
$location = "australiaeast"
# STEP 1 - create resource group
$rgName = "myview-rg"
New-AzResourceGroup -Name $rgName -Location $location
# STEP 2 - create appservice plan
$aspName = "myview-asp"
$appSku = "F1"
New-AzAppServicePlan `
-Name $aspName `
-Location $location `
-ResourceGroupName $rgName `
-Tier $appSku `
-Linux
# STEP 3 - create webapp
$appName = "myview-webapp-api"
New-AzWebApp `
-Name $appName `
-Location $location `
-AppServicePlan $aspName `
-ResourceGroupName $rgName
# STEP 4 - configure webapp
## At this point, the webapp is not using .NET v6 as is required.
## This can be verified by the following commands
az webapp config show `
--resource-group $rgName `
--name $appName `
--query netFrameworkVersion
Get-AzWebApp `
-Name $appName `
-ResourceGroupName $rgName `
| Select-Object -ExpandProperty SiteConfig `
| Select-Object -Property NetFrameworkVersion
Get-AzWebApp `
-Name $appName `
-ResourceGroupName $rgName `
| ConvertTo-Json `
| jq ".SiteConfig.NetFrameworkVersion"
## lets configure the webapp with the correct version of .NET
Set-AzWebApp `
-Name $appName `
-ResourceGroupName $rgName `
-AppServicePlan $aspName `
-NetFrameworkVersion 'v6.0'
$apiVersion = "2020-06-01"
$config = Get-AzResource `
-ResourceGroupName $rgName `
-ResourceType Microsoft.Web/sites/config `
-ResourceName $appName/web `
-ApiVersion $apiVersion
$config.Properties.linuxFxVersion = "DOTNETCORE|6.0"
$config | Set-AzResource -ApiVersion $apiVersion -Force
# cleanup
Remove-AzResourceGroup -Name $rgName -Force
Create Azure App Service Using Azure Bicep
In this example, I demonstrate both a basic and more advanced option (uses modules) to deploy bicep templates.
Azure Bicep - Basic
All the commands and templates that are required to create the Azure App Service using Azure Bicep can be found in the iac/bicep/basic
folder that is part of the example github repository
# file: iac/bicep/basic/deploy.azcli
$location = "australiaeast"
# STEP 1 - create resource group
$rgName = "myview-rg"
az group create --name $rgName --location $location
# STEP 2 - deploy template
$aspName = "myview-asp"
$appName = "myview-webapp-api"
$deploymentName = "myview-deployment"
az deployment group create `
--name $deploymentName `
--resource-group $rgName `
--template-file ./main.bicep `
--parameters appName=$appName `
--parameters aspName=$aspName
# cleanup
az group delete --resource-group $rgName -y
// file: iac/bicep/basic/main.bicep
// parameters
@description('The name of app service')
param appName string
@description('The name of app service plan')
param aspName string
@description('The location of all resources')
param location string = resourceGroup().location
@description('The Runtime stack of current web app')
param linuxFxVersion string = 'DOTNETCORE|6.0'
@allowed([
'F1'
'B1'
])
param skuName string = 'F1'
// variables
resource asp 'Microsoft.Web/serverfarms@2021-02-01' = {
name: aspName
location: location
kind: 'linux'
sku: {
name: skuName
}
properties: {
reserved: true
}
}
resource app 'Microsoft.Web/sites@2021-02-01' = {
name: appName
location: location
properties: {
serverFarmId: asp.id
httpsOnly: true
siteConfig: {
linuxFxVersion: linuxFxVersion
}
}
}
Azure Bicep - Advanced (with modules)
All the commands and templates that are required to create the Azure App Service using Azure Bicep can be found in the iac/bicep/advanced
folder that is part of the example github repository
# file: iac/bicep/advanced/deploy.azcli
$location = "australiaeast"
# STEP 1 - deploy template
$deploymentName = "myview-deployment"
az deployment sub create `
--name $deploymentName `
--location $location `
--template-file ./main.bicep `
--parameters location=$location
# STEP 2 - get outputs from deployment
az deployment sub show --name $deploymentName --query "properties.outputs"
$hostName = $(az deployment sub show --name $deploymentName --query "properties.outputs.defaultHostName.value")
$rgName = $(az deployment sub show --name $deploymentName --query "properties.outputs.resourceGroupName.value")
echo $hostName, $rgName
# STEP 3 - cleanup
az group delete --resource-group $rgName -y
// file: iac/bicep/advanced/modules/app-service.bicep
// parameters
@description('The name of app service')
param appName string
@description('The name of app service plan')
param aspName string
@description('The location of all resources')
param location string = resourceGroup().location
@description('The Runtime stack of current web app')
param linuxFxVersion string = 'DOTNETCORE|6.0'
@allowed([
'F1'
'B1'
])
param skuName string = 'F1'
// define resources
resource asp 'Microsoft.Web/serverfarms@2021-02-01' = {
name: aspName
location: location
kind: 'linux'
sku: {
name: skuName
}
properties: {
reserved: true
}
}
resource app 'Microsoft.Web/sites@2021-02-01' = {
name: appName
location: location
properties: {
serverFarmId: asp.id
httpsOnly: true
siteConfig: {
linuxFxVersion: linuxFxVersion
}
}
}
// define outputs
output defaultHostName string = app.properties.defaultHostName
// file: iac-src/bicep/advanced/main.bicep
// define scope
targetScope = 'subscription'
// define parameters
@description('The location of all resources')
param location string = deployment().location
// define variables
@description('The name of resource group')
var rgName = 'myview-rg'
@description('The name of resource group')
var aspName = 'myview-asp'
@description('The name of app')
var appName = 'myview-webapp-api'
// define resources
resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = {
name: rgName
location: location
}
// define modules
module appServiceModule 'modules/app-service.bicep' = {
scope: resourceGroup
name: 'myview-module'
params: {
appName: appName
aspName: aspName
location: location
}
}
// define outputs
output defaultHostName string = appServiceModule.outputs.defaultHostName
output resourceGroupName string = rgName
Deploy GraphQL API to Azure App Service
Publish GraphQL API Locally
dotnet publish `
--configuration Release `
--framework net6.0 `
--output ./release `
./MyView.Api
Deploy GraphQL API
cd ./release
az webapp up `
--plan myview-asp `
--name myview-webapp-api `
--resource-group myview-rg `
--os-type linux `
--runtime "DOTNETCORE:6.0"
Where To Next?
I have provided a number of examples that show how to build a GraphQL Server using ChilliCream Hot Chocolate GraphQL Server. If you would like to learn more, please view the following learning resources:
- Official Getting Started Documentation
- Official ChilliCream Hot Chocolate Examples
- On.NET Show - Getting Started with HotChocolate
- Modern data APIs with EF Core and GraphQL
- Entity Framework Community Standup - Hot Chocolate 12 and GraphQL 2021
- GraphQL API with .NET 5 and Hot Chocolate (Still applicable to .NET 6)
- GraphQL in .NET with HotChocolate (Playlist)
- Azure Tips & Tricks - How to use GraphQL on Azure
- Hot Chocolate: GraphQL Schema Stitching with ASP.Net Core - Michael Staib - NDC London 2021
HotChocolate Templates
There are also a number of Hot Chocolate templates that can be installed using the dotnet CLI
tool.
- Install HotChocolate Templates:
# install Hot Chocolate GraphQL server templates (includes Azure function template)
dotnet new -i HotChocolate.Templates
# install template that allows you to create a GraphQL Star Wars Demo
dotnet new -i HotChocolate.Templates.StarWars
- List HotChocolate Templates
# list HotChocolate templates
dotnet new --list HotChocolate
Template Name Short Name Language Tags
----------------------------------- ----------- -------- ------------------------------
HotChocolate GraphQL Function graphql-azf [C#] Web/GraphQL/Azure
HotChocolate GraphQL Server graphql [C#] Web/GraphQL
HotChocolate GraphQL Star Wars Demo starwars [C#] ChilliCream/HotChocolate/Demos
- Create HotChocolate project using templates
# create ASP.NET GraphQL Server
dotnet new graphql --name MyGraphQLDemo
# create graphql server using Azure Function
dotnet new graphql-azf --name MyGraphQLAzfDemo
# create starwars GraphQL demo
mkdir StarWars
cd StarWars
dotnet new starwars
Top comments (5)
@drminnaar This is a great post! You might want to break it up into a couple of posts and use the series feature on here to make it a little more digestible.
I've been playing around with GraphQL and .NET myself. Have used the alternative option GraphQL.NET but am about to pick up Hot Chocolate to build it again using that library. This will be a big help.
It's a cool tech but definitely needs some high level control from the engineers building it. Don't want to smash the database on every frontend request. That's how you spend mega dollars in the cloud by accident
Thanks @stphnwlsh, using a series is a great suggestion.
The support for GraphQL has improved a lot from its humble beginnings. Much respect to the builders that are working on these projects. The Chillicream offerings (both client and server) are looking more solid with every release. The latest version of HotChocolate has some good performance improvements too. I also like that you can use different approaches like code-first vs annotation-based vs schema-first.
Yeah it's only getting better and I think .NET is becoming more viable for GraphQL systems.
Code first approach seems nice to me. I prefer the models being returned to have some decent decoration/detail to them for a better user experience consuming the API. Plus not a difficult upgrade on my existing solution
I like your approaches on GitHub too. Keep making good stuff!!!!
Hello. I recommend visiting the Rich Palm casino review to anyone who wants to start playing at the best casino. By visiting this site, you can learn about the various gaming features of this casino, namely what types of games are available at this casino, bonuses, etc. It will also be useful to know how quick and easy it is to register at this casino.
I've been messing with GraphQL and .NET myself. Have utilized the elective choice GraphQL.NET yet am going to get Hot cocoa to assemble it again utilizing that library. This will be a major assistance.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.