Skip to content

Commit

Permalink
Merge pull request #74 from RobinTTY/feature/implement-basic-data-ret…
Browse files Browse the repository at this point in the history
…rieval

Implement basic data retrieval operations
  • Loading branch information
RobinTTY authored Oct 18, 2024
2 parents dcfd2bb + 9cfa526 commit c0a4ba1
Show file tree
Hide file tree
Showing 41 changed files with 1,058 additions and 254 deletions.
11 changes: 11 additions & 0 deletions deployment/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: PersonalFinanceDashboard

services:
seq:
image: datalust/seq:latest
container_name: seq
environment:
- ACCEPT_EULA=Y
ports:
- "5341:5341"
- "8001:80"
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Net.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RobinTTY.NordigenApiClient.Models;
using RobinTTY.PersonalFinanceDashboard.API.Utility;
using RobinTTY.PersonalFinanceDashboard.Infrastructure;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Mappers;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Repositories;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Services;
using RobinTTY.PersonalFinanceDashboard.ThirdPartyDataProviders;

namespace RobinTTY.PersonalFinanceDashboard.Api.Extensions;

/// <summary>
/// Provides extensions for the <see cref="IServiceCollection"/> interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers all repositories in the service collection.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
// TODO: automatic registration of repositories via codegen?
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
return services
.AddScoped<BankAccountRepository>()
.AddScoped<AuthenticationRequestRepository>()
.AddScoped<BankingInstitutionRepository>()
.AddScoped<TransactionRepository>()
.AddScoped<ThirdPartyDataRetrievalMetadataRepository>();
}

/// <summary>
/// Registers all entity mappers in the service collection.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddEntityMappers(this IServiceCollection services)
{
return services
.AddSingleton<TransactionMapper>()
.AddSingleton<BankingInstitutionMapper>()
.AddSingleton<BankAccountMapper>();
}

/// <summary>
/// Registers all necessary HotChocolate (GraphQL) features in the service collection.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddGraphQlServices(this IServiceCollection services)
{
var requestExecutorBuilder = services
// TODO: Configure cost analyzer at some point (enforces maximum query costs)
.AddGraphQLServer(disableCostAnalyzer: true)
// Adds all GraphQL query and mutation types using the code generator (looks for attributes)
.AddResolvers()
// TODO: Document what the different extensions methods do
// AddQueryConventions: https://www.youtube.com/watch?v=yoW2Mt6C0Cg
.AddQueryConventions()
.AddMutationConventions();

return requestExecutorBuilder.Services;
}

/// <summary>
/// Adds all necessary miscellaneous services for this application.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
return services
.AddSingleton<GoCardlessDataProviderService>()
.AddScoped<ThirdPartyDataRetrievalMetadataService>();
}

/// <summary>
/// Adds all necessary configuration data for this application.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddApplicationConfiguration(this IServiceCollection services)
{
var appConfig = AppConfigurationManager.AppConfiguration;
return services.AddSingleton(new NordigenClientCredentials(appConfig.NordigenApi!.SecretId,
appConfig.NordigenApi.SecretKey));
}

/// <summary>
/// Adds a configured <see cref="IHttpClientFactory"/> to the service collection.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddConfiguredHttpClient(this IServiceCollection services)
{
return services
.AddHttpClient()
.AddCors(options =>
{
// TODO: update to sensible policy
options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});
}

/// <summary>
/// Adds the database the application uses to the service collection.
/// </summary>
/// <param name="services">The service collection to which to add the services.</param>
/// <returns>A reference to the <see cref="IServiceCollection"/> after the operation has completed.</returns>
public static IServiceCollection AddDatabase(this IServiceCollection services)
{
// TODO: The filepath shouldn't be hardcoded => configuration
return services.AddDbContextPool<ApplicationDbContext>(options =>
options.UseSqlite("Data Source=../RobinTTY.PersonalFinanceDashboard.Infrastructure/application.db"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;

namespace RobinTTY.PersonalFinanceDashboard.Api.Extensions;

/// <summary>
/// Provides extensions on the <see cref="IHostBuilder"/> type.
/// </summary>
public static class WebApplicationBuilderExtensions
{
/// <summary>
/// Configures the logging of the application via Serilog.
/// </summary>
/// <param name="webApplicationBuilder">The host to configure the logging for.</param>
/// <returns>A reference to the <see cref="IHostBuilder"/> after the operation has completed.</returns>
public static IHostBuilder ConfigureSerilog(this WebApplicationBuilder webApplicationBuilder)
{
// TODO: Only do in development
// Forward issues with Serilog itself to console
if (webApplicationBuilder.Environment.IsDevelopment())
{
Serilog.Debugging.SelfLog.Enable(Console.Error);
}

return webApplicationBuilder.Host.UseSerilog((context, loggerConfig) =>
{
loggerConfig.ReadFrom.Configuration(context.Configuration);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RobinTTY.PersonalFinanceDashboard.Infrastructure;

namespace RobinTTY.PersonalFinanceDashboard.Api.Extensions;

/// <summary>
/// Provides extensions for the <see cref="WebApplication"/> type.
/// </summary>
public static class WebApplicationExtensions
{
/// <summary>
/// Configures the app to use all necessary GraphQL features.
/// </summary>
/// <param name="app">The <see cref="WebApplication"/> instance to configure.</param>
/// <param name="args">Command line arguments to be passed to the host.</param>
public static void UseGraphQl(this WebApplication app, string[] args)
{
app.MapGraphQL();

// Allows exporting the GraphQL Schema via the command line: dotnet run -- schema export --output schema.graphql
// https://chillicream.com/docs/hotchocolate/v13/server/command-line
app.RunWithGraphQLCommands(args);
}

/// <summary>
/// Applies all outstanding migrations on the database.
/// </summary>
/// <param name="app">The <see cref="WebApplication"/> instance to use to resolve the db context.</param>
public static void ApplyMigrations(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
global using System;
global using System.Linq;
global using System.Threading;
global using System.Collections.Generic;
global using System.Threading.Tasks;
global using Serilog;
global using HotChocolate;
95 changes: 11 additions & 84 deletions src/server/RobinTTY.PersonalFinanceDashboard.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,94 +1,21 @@
global using System;
global using System.Linq;
global using System.Threading;
global using System.Collections.Generic;
global using System.Threading.Tasks;
global using Serilog;
global using HotChocolate;

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RobinTTY.PersonalFinanceDashboard.API.Utility;
using RobinTTY.PersonalFinanceDashboard.ThirdPartyDataProviders;
using RobinTTY.NordigenApiClient.Models;
using RobinTTY.PersonalFinanceDashboard.Infrastructure;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Mappers;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Repositories;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Services;
using RobinTTY.PersonalFinanceDashboard.Api.Extensions;

var builder = WebApplication.CreateBuilder(args);
var appConfig = AppConfigurationManager.AppConfiguration;

// Configure logging
builder.Host.UseSerilog((context, loggerConfig) =>
{
loggerConfig.ReadFrom.Configuration(context.Configuration);
});

// HTTP setup
builder.Services
.AddHttpClient()
.AddCors(options =>
{
// TODO: update to sensible policy
options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});

// DB setup
// TODO: The filepath shouldn't be hardcoded
builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
options.UseSqlite("Data Source=../RobinTTY.PersonalFinanceDashboard.Infrastructure/application.db"));

// Mappers
builder.Services.AddSingleton<TransactionMapper>();
builder.Services.AddSingleton<BankingInstitutionMapper>();
builder.ConfigureSerilog();

// TODO: automatic registration of repositories via codegen?
// Repositories
builder.Services
.AddScoped<AccountRepository>()
.AddScoped<AuthenticationRequestRepository>()
.AddScoped<BankingInstitutionRepository>()
.AddScoped<TransactionRepository>()
.AddScoped<ThirdPartyDataRetrievalMetadataRepository>();

// Services
builder.Services
.AddScoped<ThirdPartyDataRetrievalMetadataService>();

// Others
builder.Services
.AddSingleton(new NordigenClientCredentials(appConfig.NordigenApi!.SecretId, appConfig.NordigenApi.SecretKey))
.AddSingleton<GoCardlessDataProvider>();

// TODO: Create a high level overview of the architecture that should apply
// - There can be many data providers
// - Data providers could be configured via the front-end
// - For the beginning it is smart to start with one provider to keep complexity low
// - Since the authentication/retrieval logic will differ from provider to provider, it will be difficult
// to abstract this logic away into one unified interface, so maybe I will need to refine/scrap this idea later...

// HotChocolate GraphQL setup
builder.Services
// TODO: Configure cost analyzer at some point (enforces maximum query costs)
.AddGraphQLServer(disableCostAnalyzer: true)
// TODO: Document what the different extensions methods do
.AddTypes()
// AddQueryConventions: https://www.youtube.com/watch?v=yoW2Mt6C0Cg
.AddQueryConventions()
.AddMutationConventions();
builder.Services.AddDatabase();
builder.Services.AddConfiguredHttpClient();
builder.Services.AddApplicationConfiguration();
builder.Services.AddApplicationServices();
builder.Services.AddEntityMappers();
builder.Services.AddRepositories();
builder.Services.AddGraphQlServices();

var app = builder.Build();

// Apply database migrations at startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.Migrate();
}

app.ApplyMigrations();
app.UseSerilogRequestLogging();
app.UseCors();
app.MapGraphQL();
app.UseGraphQl(args);
app.Run();
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[assembly: Module("Types")]
[assembly: Module("Resolvers")]
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
using RobinTTY.PersonalFinanceDashboard.Core.Models;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Repositories;

namespace RobinTTY.PersonalFinanceDashboard.Api.Types.Mutations;
namespace RobinTTY.PersonalFinanceDashboard.Api.Resolvers.Mutations;

/// <summary>
/// <see cref="AuthenticationRequest"/> related mutation resolvers.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using HotChocolate.Types;
using RobinTTY.PersonalFinanceDashboard.Core.Models;
using RobinTTY.PersonalFinanceDashboard.Infrastructure.Repositories;

namespace RobinTTY.PersonalFinanceDashboard.Api.Resolvers.Mutations;

/// <summary>
/// <see cref="BankAccount"/> related mutation resolvers.
/// </summary>
[MutationType]
public class BankAccountMutations
{
/// <summary>
/// Create a new banking account.
/// </summary>
/// <param name="repository">The injected repository to use for data retrieval.</param>
/// <param name="bankAccount">The banking account to create.</param>
public async Task<BankAccount> CreateBankAccount(BankAccountRepository repository, BankAccount bankAccount)
{
return await repository.AddBankAccount(bankAccount);
}

/// <summary>
/// Update an existing banking account.
/// </summary>
/// <param name="repository">The injected repository to use for data retrieval.</param>
/// <param name="bankAccount">The banking account to update.</param>
/// <returns></returns>
public async Task<BankAccount> UpdateBankAccount(BankAccountRepository repository, BankAccount bankAccount)
{
return await repository.UpdateBankAccount(bankAccount);
}

/// <summary>
/// Delete an existing bank account.
/// </summary>
/// <param name="repository">The injected repository to use for data retrieval.</param>
/// <param name="bankAccountId">The id of the bank account to delete.</param>
public async Task<bool> DeleteBankAccount(BankAccountRepository repository,
Guid bankAccountId)
{
return await repository.DeleteBankAccount(bankAccountId);
}
}
Loading

0 comments on commit c0a4ba1

Please sign in to comment.