/Dotnet5.GraphQL3.WebApplication

This project exemplify the implementation and Dockerization of a simple Razor Web MVC Core consuming a full GraphQL 3 Web API, build in a .NET 5 multi-layer project, considering development best practices, like SOLID and DRY, applying Domain-Driven concepts in a Hexagonal Architecture.

Primary LanguageC#MIT LicenseMIT

Dotnet5.GraphQL3.WebApplication

This project exemplify the implementation and Dockerization of a simple Razor Web MVC Core consuming a full GraphQL 3 Web API, build in a .NET 5 multi-layer project, considering development best practices, like SOLID and DRY, applying Domain-Driven concepts in a Hexagonal Architecture.

WebAPI
Build/Test API Image Push API CodeQL Codacy Badge
WebMVC
Build/Test MVC Image Push MVC CodeQL Codacy Badge

Docker images: WebAPI | WebMVC


home


Environment configuration

Development

Secrets

To configure database resource, init secrets in ./src/Dotnet5.GraphQL3.Store.WebAPI, and then define the DefaultConnection:

dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost,1433;Database=Store;User=sa;Password=!MyComplexPassword"

After this, to configure the HTTP client, init secrets in ./src/Dotnet5.GraphQL3.Store.WebMVC and define Store client host:

dotnet user-secrets init
dotnet user-secrets set "HttpClient:Store" "http://localhost:5000"
AppSettings

If you prefer, is possible to define it on WebAPI appsettings.Development.json and WebMVC appsettings.Development.json files:

WebAPI

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost,1433;Database=Store;User=sa;Password=!MyComplexPassword"
  }
}

WebMCV

{
  "HttpClient": {
    "Store": "http://localhost:5000"
  }
}

Production

Considering use Docker for CD (Continuous Deployment). On respective compose both web applications and sql server are in the same network, and then we can use named hosts. Already defined on WebAPI appsettings.json and WebMVC appsettings.json files:

AppSettings

WebAPI

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=mssql;Database=Store;User=sa;Password=!MyComplexPassword"
  }
}

WebMCV

{
  "HttpClient": {
    "Store": "http://webapi:5000"
  }
}

Highlights

Notifications (pattern/context)

To avoid handle exceptions, was implemented a NotificationContext that's allow all layers add business notifications through the request, with support to receive Domain notifications, that by other side, implementing validators from Fluent Validation and return a ValidationResult.

protected bool OnValidate<TEntity>(TEntity entity, AbstractValidator<TEntity> validator)
{
    ValidationResult = validator.Validate(entity);
    return IsValid;
}

protected void AddError(string errorMessage, ValidationResult validationResult = default)
{
    ValidationResult.Errors.Add(new ValidationFailure(default, errorMessage));
    validationResult?.Errors.ToList().ForEach(failure => ValidationResult.Errors.Add(failure));
}

To the GraphQL the notification context delivery a ExecutionErrors that is propagated to result from execution by a personalised Executer:

public override async Task<ExecutionResult> ExecuteAsync(string operationName, string query, Inputs variables, IDictionary<string, object> context, IServiceProvider requestServices, CancellationToken cancellationToken = new CancellationToken())
{
    var result = await base.ExecuteAsync(operationName, query, variables, context, requestServices, cancellationToken);
    var notification = requestServices.GetRequiredService<INotificationContext>();

    if (notification.HasNotifications is false) return result;

    result.Errors = notification.ExecutionErrors;
    result.Data = default;
    
    return result;
}

Resolving Scoped dependencies with Singleton Schema.

It's no more necessary after version 4.2.0 from GraphQL Server. By default, the Service Provider is already being propagated.

Is necessary, in the same personalised Executer define the service provider that will be used from resolvers on fields:

var options = base.GetOptions(operationName, query, variables, context, cancellationToken);
options.RequestServices = _serviceProvider;

Abstractions

With abstract designs, it is possible to reduce coupling in addition to applying DRY concepts, providing resources for the main behaviors:

...Domain.Abstractions

public abstract class Entity<TId>
    where TId : struct
public abstract class Builder<TBuilder, TEntity, TId> : IBuilder<TEntity, TId>
    where TBuilder : Builder<TBuilder, TEntity, TId>
    where TEntity : Entity<TId>
    where TId : struct

...Repositories.Abstractions

public abstract class Repository<TEntity, TId> : IRepository<TEntity, TId>
    where TEntity : Entity<TId>
    where TId : struct
{
    private readonly DbSet<TEntity> _dbSet;

    protected Repository(DbContext dbDbContext)
    {
        _dbSet = dbDbContext.Set<TEntity>();
    }

...Services.Abstractions

public abstract class Service<TEntity, TModel, TId> : IService<TEntity, TModel, TId>
    where TEntity : Entity<TId>
    where TModel : Model<TId>
    where TId : struct
{
    protected readonly IMapper Mapper;
    protected readonly INotificationContext NotificationContext;
    protected readonly IRepository<TEntity, TId> Repository;
    protected readonly IUnitOfWork UnitOfWork;

    protected Service(
        IUnitOfWork unitOfWork,
        IRepository<TEntity, TId> repository,
        IMapper mapper,
        INotificationContext notificationContext)
    {
        UnitOfWork = unitOfWork;
        Repository = repository;
        Mapper = mapper;
        NotificationContext = notificationContext;
    }
public abstract class MessageService<TMessage, TModel, TId> : IMessageService<TMessage, TModel, TId>
    where TMessage : class
    where TModel : Model<TId>
    where TId : struct
{
    private readonly IMapper _mapper;
    private readonly ISubject<TMessage> _subject;

    protected MessageService(IMapper mapper, ISubject<TMessage> subject)
    {
        _mapper = mapper;
        _subject = subject;
    }

From EF TPH to GraphQL Interface

ENTITY

public class ProductConfig : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder
            .HasDiscriminator()
            .HasValue<Boot>(nameof(Boot))
            .HasValue<Kayak>(nameof(Kayak))
            .HasValue<Backpack>(nameof(Backpack));
    }
}

INHERITOR

public class KayakConfig : IEntityTypeConfiguration<Kayak>
{
    public void Configure(EntityTypeBuilder<Kayak> builder)
    {
        builder
            .HasBaseType<Product>();
    }
}

INTERFACE

public sealed class ProductInterfaceGraphType : InterfaceGraphType<Product>
{
    public ProductInterfaceGraphType(BootGraphType bootGraphType, BackpackGraphType backpackGraphType, KayakGraphType kayakGraphType)
    {
        Name = "product";

        ResolveType = @object =>
        {
            return @object switch
            {
                Boot _ => bootGraphType,
                Backpack _ => backpackGraphType,
                Kayak _ => kayakGraphType,
                _ => default
            };
        };
    }
}

OBJECT

public sealed class KayakGraphType : ObjectGraphType<Kayak>
{
    public KayakGraphType()
    {
        Name = "kayak";
        Interface<ProductInterfaceGraphType>();
        IsTypeOf = o => o is Product;
    }
}

Running

The ./docker-compose.yml provide the WebAPI, WebMVC and MS SQL Server applications:

docker-compose up -d

It's possible to run without a clone of the project using the respective compose:

version: "3.7"

services:
  mssql:
    container_name: mssql
    image: mcr.microsoft.com/mssql/server
    ports:
      - 1433:1433
    environment:
      SA_PASSWORD: "!MyComplexPassword"
      ACCEPT_EULA: "Y"
    healthcheck:
      test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$SA_PASSWORD" -Q "SELECT 1" || exit 1
      interval: 10s
      timeout: 3s
      retries: 10
      start_period: 10s
    networks:
      - graphqlstore

  webapi:
    container_name: webapi
    image: antoniofalcaojr/dotnet5-graphql3-webapi
    environment:
      - ASPNETCORE_URLS=http://*:5000
    ports:
      - 5000:5000
    depends_on:
      mssql:
        condition: service_healthy
    networks:
      - graphqlstore

  webmvc:
    container_name: webmvc
    image: antoniofalcaojr/dotnet5-graphql3-webmvc
    environment:
      - ASPNETCORE_URLS=http://*:7000
    ports:
      - 7000:7000
    depends_on:
      - webapi
    networks:
      - graphqlstore

networks:
  graphqlstore:
    driver: bridge

Health checks

Based on cloud-native concepts, Readiness and Liveness integrity verification strategies were implemented.

Web API

http://localhost:5000/health/ready

Readiness

http://localhost:5000/health/live

Liveness


Web MVC

http://localhost:7000/health/ready

Readiness

http://localhost:7000/health/live

Liveness


GraphQL Playground

By default Playground respond at http://localhost:5000/ui/playground but is possible configure the host and many others details in ../...WebAPI/GraphQL/DependencyInjection/Configure.cs

app.UseGraphQLPlayground(
    new GraphQLPlaygroundOptions
    {
        Path = "/ui/playground",
        BetaUpdates = true,
        RequestCredentials = RequestCredentials.Omit,
        HideTracingResponse = false,

        EditorCursorShape = EditorCursorShape.Line,
        EditorTheme = EditorTheme.Dark,
        EditorFontSize = 14,
        EditorReuseHeaders = true,
        EditorFontFamily = "JetBrains Mono"
    });

Queries

Fragment for comparison and Arguments

QUERY

{
  First: product(id: "2c05b59b-8fb3-4cba-8698-01d55a0284e5") {
    ...comparisonFields
  }
  Second: product(id: "65af82e8-27f6-44f3-af4a-029b73f14530") {
    ...comparisonFields
  }
}

fragment comparisonFields on product {
  id
  name
  rating
  description
}

RESULT

{
  "data": {
    "First": {
      "id": "2c05b59b-8fb3-4cba-8698-01d55a0284e5",
      "name": "libero",
      "rating": 5,
      "description": "Deleniti voluptas quidem accusamus est debitis quisquam enim."
    },
    "Second": {
      "id": "65af82e8-27f6-44f3-af4a-029b73f14530",
      "name": "debitis",
      "rating": 10,
      "description": "Est veniam unde."
    }
  }
}

Query named's and Variables

QUERY

query all {
  products {
    items {
      id
      name
    }
  }
}

query byid($productId: ID!) {
  product(id: $productId) {
    id
    name
  }
}

VARIABLES

{
  "productId": "2c05b59b-8fb3-4cba-8698-01d55a0284e5"
}

HTTP BODY

{
    "operationName": "byid",
    "variables": {
        "productId": "2c05b59b-8fb3-4cba-8698-01d55a0284e5"
    },
    "query": "query all {
        products {
          items {          
            id
            name
         }
       }
    }
    query byid($productId: ID!) {
        product(id: $productId) {
          id
          name
        }
    }"
}

PLAYGROUND

queries


Variables with include, skip and default value

QUERY

query all($showPrice: Boolean = false) {
  products {
    items {
      id
      name
      price @include(if: $showPrice)
      rating @skip(if: $showPrice)
    }
  }
}

VARIABLES

{
  "showPrice": true
}

HTTP BODY

{
    "operationName": "all",
    "variables": {
        "showPrice": false
    },
    "query": "query all($showPrice: Boolean = false) {
          products {
            items {
              id
              name
              price @include(if: $showPrice)
              rating @skip(if: $showPrice)
            }
        }
    }"
}

Pagination

QUERY

{
  products(pageParams: { index: 2, size: 1 }) {
    items {
      id
    }
    pageInfo {
      current
      hasNext
      hasPrevious
      size
    }
  }
}

RESULT

{
  "data": {
    "products": {
      "items": [
        {
          "id": "3b2f6ce4-1b1d-4376-80a6-0b8d51932757"
        }
      ],
      "pageInfo": {
        "current": 2,
        "hasNext": true,
        "hasPrevious": true,
        "size": 1
      }
    }
  }
}

Mutations

MUTATION

Creating / adding a new Review to the respective product.

mutation($review: reviewInput!) {
  createReview(review: $review) {
    id
  }
}

VARIABLES

{
  "review": {
    "title": "some title",
    "comment": "some comment",
    "productId": "0fb8ec7e-7af1-4fe3-a2e2-000996ffd20f"
  }
}

RESULT

{
  "data": {
    "createReview": {
      "title": "some title"
    }
  }
}

Subscriptions

SUBSCRIPTION

The Mutation stay listening if a new review is added.

subscription {
  reviewAdded {
    title
  }
}

RESULT

{
  "data": {
    "reviewAdded": {
      "title": "Some title"
    }
  }
}

Built With

Microsoft Stack - v5.0

GraphQL Stack - v3.0

  • GraphQL - GraphQL is a query language for APIs and a runtime for fulfilling those queries with data;
  • GraphQL for .NET - This is an implementation of GraphQL in .NET;
  • GraphQL Client - A GraphQL Client for .NET over HTTP;
  • GraphQL Playground - GraphQL IDE for better development workflows.

Community Stack

  • AutoMapper - A convention-based object-object mapper;
  • FluentValidation - A popular .NET library for building strongly-typed validation rules;
  • Bogus - A simple and sane fake data generator for C#, F#, and VB.NET;
  • Bootstrap - The most popular HTML, CSS, and JS library in the world.

Contributing

Available soon!

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

See also the list of contributors who participated in this project.

License

This project is licensed under the MIT License - see the LICENSE file for details

Acknowledgments

  • Nothing more, for now.