DEV Community

Cover image for GraphQL For .NET
Douglas Minnaar
Douglas Minnaar

Posted on

GraphQL For .NET

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

 

 

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

Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

 

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 as Game and GameReview as opposed to GameDto and GameReviewDto
  • 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;
}

Enter fullscreen mode Exit fullscreen mode

 

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);
}

Enter fullscreen mode Exit fullscreen mode

 

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;
   }
}

Enter fullscreen mode Exit fullscreen mode

 

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();
   });

Enter fullscreen mode Exit fullscreen mode

 

Step 7 - Map GraphQL Endpoint

 


app.MapGraphQL("/"); // Adds a GraphQL endpoint to the endpoint configurations

Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

 

Screenshot

 

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

 

object-schema

 

Queries Schema

 

query-schema

 

Mutations Schema

 

mutation-schema

 

Write GraphQL Queries

 


# List all games and associated reviews:

query listGames {
  games {
    gameId
    title
    releasedOn
    summary
    reviews {
      reviewerId
      rating
      summary
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

 


# 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
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

 

queries

 

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
  }
}

Enter fullscreen mode Exit fullscreen mode

 

mutations

 


 

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

Enter fullscreen mode Exit fullscreen mode

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);
   }
}

Enter fullscreen mode Exit fullscreen mode

 

Step 3 - Use GameReviewValidator

 

  • The GameReviewValidator is used in the GamesMutation class to validate the GameReviewDto.

  • We use constructor dependency injection to provide the GameReviewValidator

  • The code _validator.ValidateAndThrow(gameReview); will execute the validation rules defined for GameReviewDto 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);

      .
      .
      .
   }
}

Enter fullscreen mode Exit fullscreen mode

 

Step 4 - Add Validation Service

 


// configure fluent validation for GameReviewDto
builder.Services.AddTransient<AbstractValidator<GameReviewDto>, GameReviewValidator>();

Enter fullscreen mode Exit fullscreen mode

 

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);
      .
      .
      .
  }

Enter fullscreen mode Exit fullscreen mode

 


  {
    "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`..."
                }
        }
    ]
  }

Enter fullscreen mode Exit fullscreen mode

 

  • Delete Game  

  public GameReviewDto DeleteGameReview(Guid gameId, Guid reviewerId)
  {
      var game = GameData
         .Games
         .FirstOrDefault(game => game.GameId == gameId)
         ?? throw GameNotFoundException.WithGameId(gameId);
         .
         .
         .
  }

Enter fullscreen mode Exit fullscreen mode

 


  {
    "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 ..."
            }
        }    
    ]
  }

Enter fullscreen mode Exit fullscreen mode

 

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();
   }
}

Enter fullscreen mode Exit fullscreen mode

 

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();
        }

        .
        .
        .
    }
}

Enter fullscreen mode Exit fullscreen mode

 

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();
        }

        .
        .
        .
    }
}

Enter fullscreen mode Exit fullscreen mode

 

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();
        }

        .
        .
        .
    }
}

Enter fullscreen mode Exit fullscreen mode

 

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();
   });

Enter fullscreen mode Exit fullscreen mode

 

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."
            }
        }
    ]  
  }

Enter fullscreen mode Exit fullscreen mode

 

  • 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"
            }
        }
    ]
  }

Enter fullscreen mode Exit fullscreen mode

 

  • 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"
            }
          }
      ]
  }

Enter fullscreen mode Exit fullscreen mode

 


 

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

Enter fullscreen mode Exit fullscreen mode

 

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>();
}

Enter fullscreen mode Exit fullscreen mode

 

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);
   }
}

Enter fullscreen mode Exit fullscreen mode

 

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());

Enter fullscreen mode Exit fullscreen mode

 

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
      };
   }

Enter fullscreen mode Exit fullscreen mode

 

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
      };
   }

Enter fullscreen mode Exit fullscreen mode

 

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)
{
    ...
}

Enter fullscreen mode Exit fullscreen mode

 

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()
   ...
   );

Enter fullscreen mode Exit fullscreen mode

 

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 = @"
      [
         .
         .
         .
      ]";
}


Enter fullscreen mode Exit fullscreen mode

 

Configure when to Seed database

 


// Program.cs

app.MapGraphQL("/");

// seed database after all middleware is setup
await Seeder.SeedDatabase(app);

Enter fullscreen mode Exit fullscreen mode

 


 

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

Enter fullscreen mode Exit fullscreen mode

 

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);
   }
}

Enter fullscreen mode Exit fullscreen mode

 

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());

Enter fullscreen mode Exit fullscreen mode

 

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"

Enter fullscreen mode Exit fullscreen mode

 

Create and Update SQLite Database

 


# run the migrations to create database
dotnet ef database update

Enter fullscreen mode Exit fullscreen mode

 


 

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

Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

 


// 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
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

 


// 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

Enter fullscreen mode Exit fullscreen mode

 


// 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

Enter fullscreen mode Exit fullscreen mode

 

Deploy GraphQL API to Azure App Service

 

Publish GraphQL API Locally

 


dotnet publish `
  --configuration Release `
  --framework net6.0 `
  --output ./release `
  ./MyView.Api

Enter fullscreen mode Exit fullscreen mode

 

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"

Enter fullscreen mode Exit fullscreen mode

 


 

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:

 

 

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

Enter fullscreen mode Exit fullscreen mode

 

  • List HotChocolate Templates  

  # list HotChocolate templates
  dotnet new --list HotChocolate

Enter fullscreen mode Exit fullscreen mode

  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

Enter fullscreen mode Exit fullscreen mode

 

  • 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

Enter fullscreen mode Exit fullscreen mode

 


Discussion (3)

Collapse
stphnwlsh profile image
Stephen Walsh

@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

Collapse
drminnaar profile image
Douglas Minnaar Author

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.

Collapse
stphnwlsh profile image
Stephen Walsh

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!!!!