Implement unified API controller pattern

This commit is contained in:
2020-02-29 14:13:46 +01:00
parent e49b6791a0
commit eee5661f9a
16 changed files with 414 additions and 138 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
bin/
obj/
/packages/
.blob
*.blob
.idea/

View File

@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using EstusShots.Shared.Dto;
using EstusShots.Client.Routes;
using EstusShots.Shared.Interfaces;
using EstusShots.Shared.Models;
namespace EstusShots.Client
@@ -13,49 +13,52 @@ namespace EstusShots.Client
{
private readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNameCaseInsensitive = true
};
private string ApiUrl { get; }
private HttpClient HttpClient { get; }
public string ApiUrl { get; }
// API Routes
public Seasons Seasons { get; }
/// <summary>
/// Creates a new instance of <see cref="EstusShotsClient"/>
/// </summary>
/// <param name="apiUrl">Base URL of the Estus Shots API host</param>
public EstusShotsClient(string apiUrl)
{
ApiUrl = apiUrl;
HttpClient = new HttpClient {Timeout = TimeSpan.FromSeconds(10)};
Seasons = new Seasons(this);
}
public async Task<(OperationResult, List<Season>)> GetSeasons()
/// <summary>
/// Generic method to post a request to the API
/// </summary>
/// <param name="url">URL to the desired action</param>
/// <param name="parameter">The API parameter object instance</param>
/// <typeparam name="TResult">API response class that implements <see cref="IApiResponse"/></typeparam>
/// <typeparam name="TParam">API parameter class that implements <see cref="IApiParameter"/></typeparam>
/// <returns></returns>
public async Task<ApiResponse<TResult>> PostToApi<TResult, TParam>(string url, TParam parameter)
where TParam : IApiParameter, new()
where TResult : class, IApiResponse, new()
{
try
{
var response = await HttpClient.GetAsync(ApiUrl + "seasons");
var jsonData = await response.Content.ReadAsStringAsync();
var data = JsonSerializer.Deserialize<List<Season>>(jsonData, _serializerOptions);
return (new OperationResult(), data);
var serialized = JsonSerializer.Serialize(parameter);
var content = new StringContent(serialized, Encoding.UTF8, "application/json");
var response = await HttpClient.PostAsync(url, content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<ApiResponse<TResult>>(json, _serializerOptions);
return result;
}
catch (Exception e)
{
return (new OperationResult(e), new List<Season>());
}
}
public async Task<(OperationResult, Guid)> CreateSeason(Season season)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(season), Encoding.UTF8, "application/json");
var response = await HttpClient.PostAsync(ApiUrl + "season", content);
if (!response.IsSuccessStatusCode)
{
return (new OperationResult(false, response.ReasonPhrase), Guid.Empty);
}
// TODO should give the created id
return (new OperationResult(), Guid.Empty);
}
catch (Exception e)
{
return (new OperationResult(e), Guid.Empty);
return new ApiResponse<TResult>(new OperationResult(e));
}
}

View File

@@ -0,0 +1,30 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using EstusShots.Shared.Interfaces;
using EstusShots.Shared.Models;
using EstusShots.Shared.Models.Parameters;
namespace EstusShots.Client.Routes
{
public class Seasons : ISeasonsController
{
private readonly EstusShotsClient _client;
public Seasons(EstusShotsClient client)
{
_client = client;
}
private string ActionUrl([CallerMemberName]string caller = "") =>
$"{_client.ApiUrl}{nameof(Seasons)}/{caller}";
public async Task<ApiResponse<GetSeasonsResponse>> GetSeasons(GetSeasonsParameter parameter) =>
await _client.PostToApi<GetSeasonsResponse, GetSeasonsParameter>(ActionUrl(), parameter);
public async Task<ApiResponse<GetSeasonResponse>> GetSeason(GetSeasonParameter parameter) =>
await _client.PostToApi<GetSeasonResponse, GetSeasonParameter>(ActionUrl(), parameter);
public async Task<ApiResponse<SaveSeasonResponse>> SaveSeason(SaveSeasonParameter parameter)=>
await _client.PostToApi<SaveSeasonResponse, SaveSeasonParameter>(ActionUrl(), parameter);
}
}

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using EstusShots.Client;
using EstusShots.Gtk.Controls;
using EstusShots.Shared.Dto;
using EstusShots.Shared.Models.Parameters;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;
@@ -63,6 +64,7 @@ namespace EstusShots.Gtk
private async void NewSeasonButtonOnClicked(object sender, EventArgs e)
{
using var _ = new LoadingMode(this);
// TODO real season edit control
var season = new Season
{
Game = "Test Game",
@@ -70,14 +72,13 @@ namespace EstusShots.Gtk
Start = DateTime.Now,
Description = "This is a demo description!"
};
var (res, _) = await Client.CreateSeason(season);
if (!res.Success)
var parameter = new SaveSeasonParameter(season);
var res = await Client.Seasons.SaveSeason(parameter);
if (!res.OperationResult.Success)
{
_infoLabel.Text = $"Error while creating Season: {res.ShortMessage}";
_infoLabel.Text = $"Error while creating Season: {res.OperationResult.ShortMessage}";
return;
}
await ReloadSeasons();
Info("Created new Season");
}
@@ -91,14 +92,15 @@ namespace EstusShots.Gtk
private async Task ReloadSeasons()
{
var (res, seasons) = await Task.Factory.StartNew(() => Client.GetSeasons().Result);
if (!res.Success)
var res = await Task.Factory.StartNew(
() => Client.Seasons.GetSeasons(new GetSeasonsParameter()).Result);
if (!res.OperationResult.Success)
{
_infoLabel.Text = $"Refresh Failed: {res.ShortMessage}";
_infoLabel.Text = $"Refresh Failed: {res.OperationResult.ShortMessage}";
return;
}
SeasonsControl.Items = seasons;
SeasonsControl.Items = res.Data.Seasons;
SeasonsControl.DataBind();
Info("List Refreshed");
}

View File

@@ -1,54 +0,0 @@
using System;
using System.Threading.Tasks;
using AutoMapper;
using EstusShots.Server.Models;
using EstusShots.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Dto = EstusShots.Shared.Dto;
namespace EstusShots.Server.Controllers
{
[ApiController]
[Route("/api/[controller]")]
public class SeasonController : ControllerBase
{
private readonly EstusShotsContext _context;
private readonly IMapper _mapper;
private readonly ILogger _logger;
public SeasonController(EstusShotsContext context, IMapper mapper, ILogger<SeasonController> logger)
{
_context = context;
_mapper = mapper;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<ActionResult<Dto.Season>> GetSeason(Guid id)
{
var season = await _context.Seasons.FindAsync(id);
if (season == null) {return NotFound();}
var seasonDto = _mapper.Map<Dto.Season>(season);
return seasonDto;
}
[HttpPost]
public async Task<ActionResult<Dto.Season>> CreateSeason(Dto.Season season)
{
var dbSeason = _mapper.Map<Season>(season);
_context.Seasons.Add(dbSeason);
try
{
await _context.SaveChangesAsync();
_logger.LogInformation("New season created");
}
catch (Exception e)
{
_logger.LogError(e, "Error while saving Season");
}
return CreatedAtAction(nameof(GetSeason), new {id = dbSeason.SeasonId}, dbSeason);
}
}
}

View File

@@ -1,33 +1,70 @@
using System.Collections.Generic;
using System;
using System.Threading.Tasks;
using AutoMapper;
using EstusShots.Server.Services;
using EstusShots.Shared.Interfaces;
using EstusShots.Shared.Models;
using EstusShots.Shared.Models.Parameters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Dto = EstusShots.Shared.Dto;
using Microsoft.Extensions.Logging;
namespace EstusShots.Server.Controllers
{
[ApiController]
[Route("/api/[controller]")]
public class SeasonsController : ControllerBase
[Route("/api/[controller]/[action]")]
public class SeasonsController : ControllerBase, ISeasonsController
{
private readonly EstusShotsContext _context;
private readonly IMapper _mapper;
private readonly ILogger _logger;
private readonly SeasonsService _seasonsService;
public SeasonsController(EstusShotsContext context, IMapper mapper)
public SeasonsController(ILogger<SeasonsController> logger, SeasonsService seasonsService)
{
_context = context;
_mapper = mapper;
_seasonsService = seasonsService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<List<Dto.Season>>> GetSeasons()
[HttpPost]
public async Task<ApiResponse<GetSeasonsResponse>> GetSeasons(GetSeasonsParameter parameter)
{
var seasons = await _context.Seasons.ToListAsync();
var dtos = _mapper.Map<List<Dto.Season>>(seasons);
return dtos;
try
{
_logger.LogInformation($"Request received from client '{Request.HttpContext.Connection.RemoteIpAddress}'");
return await _seasonsService.GetSeasons(parameter);
}
catch (Exception e)
{
_logger.LogError(e, "Exception Occured");
return new ApiResponse<GetSeasonsResponse>(new OperationResult(e));
}
}
[HttpPost]
public async Task<ApiResponse<GetSeasonResponse>> GetSeason(GetSeasonParameter parameter)
{
try
{
_logger.LogInformation($"Request received from client '{Request.HttpContext.Connection.RemoteIpAddress}'");
return await _seasonsService.GetSeason(parameter);
}
catch (Exception e)
{
_logger.LogError(e, "Exception Occured");
return new ApiResponse<GetSeasonResponse>(new OperationResult(e));
}
}
public async Task<ApiResponse<SaveSeasonResponse>> SaveSeason(SaveSeasonParameter parameter)
{
try
{
_logger.LogInformation($"Request received from client '{Request.HttpContext.Connection.RemoteIpAddress}'");
return await _seasonsService.SaveSeason(parameter);
}
catch (Exception e)
{
_logger.LogError(e, "Exception Occured");
return new ApiResponse<SaveSeasonResponse>(new OperationResult(e));
}
}
}
}

View File

@@ -1,23 +1,7 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:26850",
"sslPort": 44318
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"EstusShots.Server": {
"EstusShots.Server (Dev)": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "",
@@ -25,6 +9,15 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"EstusShots.Server (Prod)": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
}
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutoMapper;
using EstusShots.Server.Models;
using EstusShots.Shared.Interfaces;
using EstusShots.Shared.Models;
using EstusShots.Shared.Models.Parameters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Dto = EstusShots.Shared.Dto;
namespace EstusShots.Server.Services
{
public class SeasonsService : ISeasonsController
{
private readonly EstusShotsContext _context;
private readonly IMapper _mapper;
private readonly ILogger _logger;
public SeasonsService(EstusShotsContext context, IMapper mapper, ILogger<SeasonsService> logger)
{
_context = context;
_mapper = mapper;
_logger = logger;
}
public async Task<ApiResponse<GetSeasonsResponse>> GetSeasons(GetSeasonsParameter parameter)
{
var seasons = await _context.Seasons.ToListAsync();
var dtos = _mapper.Map<List<Dto.Season>>(seasons);
return new ApiResponse<GetSeasonsResponse>(new GetSeasonsResponse(dtos));
}
public async Task<ApiResponse<GetSeasonResponse>> GetSeason(GetSeasonParameter parameter)
{
var season = await _context.Seasons.FindAsync(parameter.SeasonId);
if (season == null)
{
_logger.LogWarning($"Season '{parameter.SeasonId}' not found in database");
return new ApiResponse<GetSeasonResponse>(new OperationResult(false, $"Season '{parameter.SeasonId}' not found in database"));
}
var seasonDto = _mapper.Map<Dto.Season>(season);
return new ApiResponse<GetSeasonResponse>(new GetSeasonResponse(seasonDto));
}
public async Task<ApiResponse<SaveSeasonResponse>> SaveSeason(SaveSeasonParameter parameter)
{
var season = _mapper.Map<Season>(parameter.Season);
var existing = await _context.Seasons.FindAsync(season.SeasonId);
if (existing == null)
{
_context.Seasons.Add(season);await _context.SaveChangesAsync();
_logger.LogInformation($"New season created: '{season.SeasonId}'");
}
else
{
throw new NotImplementedException();
}
return new ApiResponse<SaveSeasonResponse>(new SaveSeasonResponse(season.SeasonId));
}
}
}

View File

@@ -6,37 +6,46 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using IHostingEnvironment = Microsoft.AspNetCore.Hosting.IHostingEnvironment;
namespace EstusShots.Server
{
public class Startup
{
public IConfiguration Configuration { get; }
private bool IsDevelopment { get; set; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<EstusShotsContext>(
opt => opt.UseInMemoryDatabase("debug.db"));
services.AddAutoMapper(typeof(Startup));
services.AddControllers();
services.AddControllers().AddJsonOptions(options =>
{
if (IsDevelopment)
{
options.JsonSerializerOptions.WriteIndented = true;
}
});
services.AddScoped<SeasonsService>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
IsDevelopment = env.IsDevelopment();
if (IsDevelopment)
{
app.UseDeveloperExceptionPage();
}
if (!env.IsDevelopment())
else
{
app.UseHttpsRedirection();
}

View File

@@ -8,9 +8,9 @@ namespace EstusShots.Shared.Dto
public int Number { get; set; }
public string Game { get; set; } = default!;
public string Game { get; set; }
public string? Description { get; set; }
public string Description { get; set; }
public DateTime Start { get; set; }

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Nullable>disable</Nullable>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

View File

@@ -0,0 +1,12 @@
namespace EstusShots.Shared.Interfaces
{
/// <summary>
/// Marks a class that can be used as a parameter for API requests
/// </summary>
public interface IApiParameter { }
/// <summary>
/// Marks a class as response type that is returned from API requests
/// </summary>
public interface IApiResponse { }
}

View File

@@ -0,0 +1,23 @@
using System.Threading.Tasks;
using EstusShots.Shared.Models;
using EstusShots.Shared.Models.Parameters;
namespace EstusShots.Shared.Interfaces
{
/// <summary>
/// Access many seasons with one request
/// </summary>
public interface ISeasonsController
{
/// <summary>
/// Get a list of all seasons in the system
/// </summary>
/// <param name="parameter"></param>
/// <returns></returns>
Task<ApiResponse<GetSeasonsResponse>> GetSeasons(GetSeasonsParameter parameter);
Task<ApiResponse<GetSeasonResponse>> GetSeason(GetSeasonParameter parameter);
Task<ApiResponse<SaveSeasonResponse>> SaveSeason(SaveSeasonParameter parameter);
}
}

View File

@@ -29,4 +29,35 @@ namespace EstusShots.Shared.Models
StackTrace = e.StackTrace;
}
}
public class ApiResponse<T> where T : class, new()
{
public OperationResult OperationResult { get; set; }
public T Data { get; set; }
public ApiResponse()
{
OperationResult = new OperationResult();
Data = new T();
}
public ApiResponse(OperationResult operationResult)
{
OperationResult = operationResult;
Data = new T();
}
public ApiResponse(T data)
{
OperationResult = new OperationResult();
Data = data;
}
public ApiResponse(OperationResult operationResult, T data)
{
OperationResult = operationResult;
Data = data;
}
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using EstusShots.Shared.Dto;
using EstusShots.Shared.Interfaces;
namespace EstusShots.Shared.Models.Parameters
{
// GetSeasons
/// <summary>
/// Parameter class for loading a list of all seasons from the database
/// </summary>
public class GetSeasonsParameter : IApiParameter { }
/// <summary>
/// Parameter class returned from the API with all loaded seasons
/// </summary>
public class GetSeasonsResponse : IApiResponse
{
/// <summary>
/// All existing seasons in the database
/// </summary>
public List<Season> Seasons { get; set; }
public GetSeasonsResponse(List<Season> seasons)
{
Seasons = seasons;
}
public GetSeasonsResponse()
{
Seasons = new List<Season>();
}
}
// GetSeason
/// <summary>
/// Parameter class for loading a single season object
/// </summary>
public class GetSeasonParameter : IApiParameter
{
/// <summary>
/// ID of the season that should be loaded
/// </summary>
public Guid SeasonId { get; set; }
public GetSeasonParameter()
{
SeasonId = Guid.Empty;
}
public GetSeasonParameter(Guid seasonId)
{
SeasonId = seasonId;
}
}
/// <summary>
/// Parameter class returned from the API after loading a single season object
/// </summary>
public class GetSeasonResponse : IApiResponse
{
/// <summary>
/// The loaded season
/// </summary>
public Season Season { get; set; }
public GetSeasonResponse()
{
Season = new Season();
}
public GetSeasonResponse(Season season)
{
Season = season;
}
}
// SaveSeason
/// <summary>
/// Parameter class for saving season objects
/// </summary>
public class SaveSeasonParameter : IApiParameter
{
/// <summary>
/// The season object that should be saved
/// </summary>
public Season Season { get; set; }
public SaveSeasonParameter()
{
Season = new Season();
}
public SaveSeasonParameter(Season season)
{
Season = season;
}
}
/// <summary>
/// Parameter class returned from the API after saving a season object
/// </summary>
public class SaveSeasonResponse : IApiResponse
{
/// <summary>
/// ID of the season that was updated or created
/// </summary>
public Guid SeasonId { get; set; }
public SaveSeasonResponse()
{
SeasonId = Guid.Empty;
}
public SaveSeasonResponse(Guid seasonId)
{
SeasonId = seasonId;
}
}
}

View File

@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=dtos/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Estus/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>