Skip to content

Instructions 01 MinimalAPI

Christian Nagel edited this page Sep 28, 2023 · 2 revisions

Part 1 - Minimal API

The game will be accessible via a REST API implemented with ASP.NET Core.

A Model

This step can be skipped by using the CNinnovation.Codebreaker.BackendModels NuGet package.

Check the code of the Codebreaker.GameAPIs.Models project. The classes Game and Move represent a stored game.

public class Game(
    Guid gameId,
    string gameType,
    string playerName,
    DateTime startTime,
    int numberCodes,
    int maxMoves) : IGame
{
    public Guid GameId { get; } = gameId;
    public string GameType { get; } = gameType;
    public string PlayerName { get; } = playerName;
    public DateTime StartTime { get; } = startTime;
    public DateTime? EndTime { get; set; }
    public TimeSpan? Duration { get; set; }
    public int LastMoveNumber { get; set; } = 0;
    public int NumberCodes { get; private set; } = numberCodes;
    public int MaxMoves { get; private set; } = maxMoves;
    public bool IsVictory { get; set; } = false;

    public required IDictionary<string, IEnumerable<string>> FieldValues { get; init; }

    public required string[] Codes { get; init; }
    public ICollection<Move> Moves { get; } = new List<Move>();

    public override string ToString() => $"{GameId}:{GameType} - {StartTime}";
}
public class Move(Guid moveId, int moveNumber)
{
    public Guid MoveId { get; private set; } = moveId;
    public int MoveNumber { get; } = moveNumber;
    public required string[] GuessPegs { get; init; }
    public required string[] KeyPegs { get; init; }

    public override string ToString() => $"{MoveNumber}. {string.Join(':', GuessPegs)}";
}

The Game class implements the IGame interface which is used by game analyers zu analyze a game move.

The Minimal API

Create an ASP.NET Core 8 project hosting the Minimal API

In-Memory Games Repository

Create an in-memory games repository class GamesMemoryRepository implementing the interface IGamesRepository. The most important methods are AddGameAsync, AddMoveAsync, and GetGameAsync. All the other methods can throw a NotSupportedException with a simple game flow.

public class GamesMemoryRepository(ILogger<GamesMemoryRepository> logger) : IGamesRepository
{
    private readonly ConcurrentDictionary<Guid, Game> _games = new();
    private readonly ILogger _logger = logger;

    public Task AddGameAsync(Game game, CancellationToken cancellationToken = default)
    {
        if (!_games.TryAdd(game.GameId, game))
        {
            _logger.LogWarning("gameid {gameId} already exists", game.GameId);
        }
        return Task.CompletedTask;
    }

    public Task AddMoveAsync(Game game, Move move, CancellationToken cancellationToken = default)
    {
        _games[game.GameId] = game;
        return Task.CompletedTask;
    }    

    public Task<Game?> GetGameAsync(Guid gameId, CancellationToken cancellationToken = default)
    {
        _games.TryGetValue(gameId, out Game? game);
        return Task.FromResult(game);
    }	

Games Factory

Create a GamesFactory class. The class from the repo supports different game types. Today we only need the Game6x4 game type. This factory uses a game analyzer from the analyzer library to calculate the move result.

public static class GamesFactory
{
    private static readonly string[] s_colors6 = [Colors.Red, Colors.Green, Colors.Blue, Colors.Yellow, Colors.Purple, Colors.Orange];

    /// <summary>
    /// Creates a game object with specified gameType and playerName.
    /// </summary>
    /// <param name="gameType">The type of game to be created.</param>
    /// <param name="playerName">The name of the player.</param>
    /// <returns>The created game object.</returns>
    public static Game CreateGame(string gameType, string playerName)
    {
        Game Create6x4Game() =>
            new(Guid.NewGuid(), gameType, playerName, DateTime.Now, 4, 12)
            {
                FieldValues = new Dictionary<string, IEnumerable<string>>()
                {
                    { FieldCategories.Colors, s_colors6 }
                },
                Codes = Random.Shared.GetItems(s_colors6, 4)
            };
        
        return gameType switch
        {
            GameTypes.Game6x4 => Create6x4Game(),
            _ => throw new CodebreakerException("Invalid game type") { Code = CodebreakerExceptionCodes.InvalidGameType }
        };
    }

    /// <summary>
    /// Applies a player's move to a game and returns a <see cref="Move"/> object that encapsulates the player's guess and the result of the guess.
    /// </summary>
    /// <param name="game">The game to apply the move to.</param>
    /// <param name="guesses">The player's guesses.</param>
    /// <param name="moveNumber">The move number.</param>
    /// <returns>A <see cref="Move"/> object that encapsulates the player's guess and the result of the guess.</returns>
    public static Move ApplyMove(this Game game, string[] guesses, int moveNumber)
    {
        static TField[] GetGuesses<TField>(IEnumerable<string> guesses)
            where TField : IParsable<TField> => 
            guesses
                .Select(g => TField.Parse(g, default))
                .ToArray();

        Move GetColorGameGuessAnalyzerResult()
        {
            ColorGameGuessAnalyzer analyzer = new (game, GetGuesses<ColorField>(guesses), moveNumber);

            ColorResult result = analyzer.GetResult();
            return new(Guid.NewGuid(), moveNumber)
            {
                GuessPegs = guesses,
                KeyPegs = result.ToStringResults()
            };
        }

        Move move = game.GameType switch
        {
            GameTypes.Game6x4 => GetColorGameGuessAnalyzerResult(),
            _ => throw new CodebreakerException("Invalid game type") { Code = CodebreakerExceptionCodes.InvalidGameType }
        };

        game.Moves.Add(move);
        return move;
    }
}

Games service contract and implementation

Define the IGamesService contract for a game run:

public interface IGamesService
{
    Task<Game> StartGameAsync(string gameType, string playerName, CancellationToken cancellationToken = default);

    Task<(Game Game, Move Move)> SetMoveAsync(Guid gameId, string[] guesses, int moveNumber, CancellationToken cancellationToken = default);

    ValueTask<Game> GetGameAsync(Guid id, CancellationToken cancellationToken = default);
}

Implement the GamesService class which injects the IGamesRepository:

public class GamesService(IGamesRepository dataRepository) : IGamesService
{
    private readonly IGamesRepository _dataRepository = dataRepository;

    public async Task<Game> StartGameAsync(string gameType, string playerName, CancellationToken cancellationToken = default)
    {
        Game game = GamesFactory.CreateGame(gameType, playerName);

        await _dataRepository.AddGameAsync(game, cancellationToken);
        return game;
    }

    public async Task<(Game Game, Move Move)> SetMoveAsync(Guid gameId, string[] guesses, int moveNumber, CancellationToken cancellationToken = default)
    {
        Game? game = await _dataRepository.GetGameAsync(gameId, cancellationToken);
        CodebreakerException.ThrowIfNull(game);
        CodebreakerException.ThrowIfEnded(game);

        Move move = game.ApplyMove(guesses, moveNumber);

        // Update the game in the game-service database
        await _dataRepository.AddMoveAsync(game, move, cancellationToken);

        return (game, move);
    }

    // get the game from the cache or the data repository
    public async ValueTask<Game> GetGameAsync(Guid gameId, CancellationToken cancellationToken = default)
    {
        var game = await _dataRepository.GetGameAsync(gameId, cancellationToken);
        CodebreakerException.ThrowIfNull(game);
        return game;
    }
}

Models for the API data transfer

Implement the DTOs for the API data transfer:

[JsonConverter(typeof(JsonStringEnumConverter<GameType>))]
public enum GameType
{
    Game6x4,
    TBD
}

public record class CreateGameRequest(GameType GameType, string PlayerName);

public record class CreateGameResponse(Guid GameId, GameType GameType, string PlayerName, int NumberCodes, int MaxMoves)
{
    public required IDictionary<string, IEnumerable<string>> FieldValues { get; init; }
}

public record class UpdateGameRequest(Guid GameId, GameType GameType, string PlayerName, int MoveNumber, bool End = false)
{
    public string[]? GuessPegs { get; set; }
}

public record class UpdateGameResponse(
    Guid GameId,
    GameType GameType,
    int MoveNumber,
    bool Ended,
    bool IsVictory,
    string[]? Results);

Game Endpoint

Define a game endpoint and define a group:

    public static void MapGameEndpoints(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/games")
            .WithTags("Games API");

Using the group, specify MapPost to start a game:

        group.MapPost("/", async Task<Results<Created<CreateGameResponse>, BadRequest<GameError>>> (
            CreateGameRequest request,
            IGamesService gameService,
            HttpContext context,
            CancellationToken cancellationToken) =>
        {
            Game game;
            try
            {
                game = await gameService.StartGameAsync(request.GameType.ToString(), request.PlayerName, cancellationToken);
            }
            catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.InvalidGameType)
            {
                GameError error = new(ErrorCodes.InvalidGameType, $"Game type {request.GameType} does not exist", context.Request.GetDisplayUrl(),   Enum.GetNames<GameType>());
                return TypedResults.BadRequest(error);
            }
            return TypedResults.Created($"/games/{game.GameId}", game.AsCreateGameResponse());
        })
        .WithName("CreateGame")
        .WithSummary("Creates and starts a game")
        .WithOpenApi(op =>
        {
            op.RequestBody.Description = "The game type and the player name of the game to create";
            return op;
        });

Using the group, specify MapPatch to update the game with a move:

        // Update the game resource with a move
        group.MapPatch("/{gameId:guid}", async Task<Results<Ok<UpdateGameResponse>, NotFound, BadRequest<GameError>>> (
            Guid gameId,
            UpdateGameRequest request,
            IGamesService gameService,
            HttpContext context,
            CancellationToken cancellationToken) =>
        {
            if (!request.End && request.GuessPegs == null)
            {
                return TypedResults.BadRequest(new GameError(ErrorCodes.InvalidMove, "End the game or set guesses", context.Request.GetDisplayUrl()));
            }
            try
            {
                if (request.End)
                {
                    Game? game = await gameService.EndGameAsync(gameId, cancellationToken);
                    if (game is null)
                        return TypedResults.NotFound();
                    return TypedResults.Ok(game.AsUpdateGameResponse());
                }
                else
                {
                    (Game game, Move move) = await gameService.SetMoveAsync(gameId, request.GuessPegs!, request.MoveNumber, cancellationToken);
                    return TypedResults.Ok(game.AsUpdateGameResponse(move.KeyPegs));
                }
            }
            catch (ArgumentException ex) when (ex.HResult is >= 4200 and <= 4500)
            {
                string url = context.Request.GetDisplayUrl();
                return ex.HResult switch
                {
                    4200 => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidGuessNumber, "Invalid number of guesses received", url)),
                    4300 => TypedResults.BadRequest(new GameError(ErrorCodes.UnexpectedMoveNumber, "Unexpected move number received", url)),
                    4400 => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidGuess, "Invalid guess values received!", url)),
                    _ => TypedResults.BadRequest(new GameError(ErrorCodes.InvalidMove,"Invalid move received!", url))
                };
            }
            catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.GameNotFound)
            {
                return TypedResults.NotFound();
            }
            catch (CodebreakerException ex) when (ex.Code == CodebreakerExceptionCodes.GameNotActive)
            {
                string url = context.Request.GetDisplayUrl();
                return TypedResults.BadRequest(new GameError(ErrorCodes.GameNotActive, "The game already ended", url));
            }
        })
        .WithName("SetMove")
        .WithSummary("End the game or set a move")
        .WithOpenApi(op =>
        {
            op.Parameters[0].Description = "The id of the game to set a move";
            op.RequestBody.Description = "The data for creating the move";
            return op;
        });

Define the MapGet method to return a single game:

        // Get game by id
        group.MapGet("/{gameId:guid}", async Task<Results<Ok<Game>, NotFound>> (
            Guid gameId,
            IGamesService gameService,
            CancellationToken cancellationToken
        ) =>
        {
            Game? game = await gameService.GetGameAsync(gameId, cancellationToken);

            if (game is null)
            {
                return TypedResults.NotFound();
            }

            return TypedResults.Ok(game);
        })
        .WithName("GetGame")
        .WithSummary("Gets a game by the given id")
        .WithOpenApi(op =>
        {
            op.Parameters[0].Description = "The id of the game to get";
            return op;
        });

Configure the Middleware

builder.Services.AddSingleton<IGamesRepository, GamesMemoryRepository>();
builder.Services.AddScoped<IGamesService, GamesService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        // options.InjectStylesheet("/swagger-ui/swaggerstyle.css");
        options.SwaggerEndpoint("/swagger/v3/swagger.json", "v3");
    });
}

app.MapGameEndpoints();

HTTP File

Create an HTTP file to test the API:

@Codebreaker.GameAPIs_HostAddress = http://localhost:9400
@ContentType = application/json

### Create a game
POST {{Codebreaker.GameAPIs_HostAddress}}/games/
Content-Type: {{ContentType}}

{
  "gameType": "Game6x4",
  "playerName": "test"
}

### Set a move

@gameid = 9421c1cc-4cbf-4485-83c9-da02d47b383e

PATCH {{Codebreaker.GameAPIs_HostAddress}}/games/{{gameid}}
Content-Type: {{ContentType}}

{
  "gameType": "Game6x4",
  "playerName": "test",
  "moveNumber": 4,
  "guessPegs": [
    "Green",
    "Red",
    "Green",
    "Red"
  ]
}

### Get game information

GET {{Codebreaker.GameAPIs_HostAddress}}/games/{{gameid}}

Run the service

Run the service locally and test it with the HTTP file.

Clone this wiki locally