From a1ba2f9571d1210bba63452f55c046f2652bcf30 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sat, 1 Mar 2025 16:57:18 +0100 Subject: [PATCH] Added backend functionality --- WorkTime.Defaults/DataResult.cs | 83 +++++++++++++ WorkTime.Defaults/Extensions.cs | 110 ++++++++++++++++++ WorkTime.Defaults/WorkTime.Defaults.csproj | 22 ++++ WorkTime.sln | 7 ++ .../Controller/EntryController.cs | 28 +++++ src/WorkTime.Api/DatabaseContext.cs | 10 ++ src/WorkTime.Api/Models/TimeEntry.cs | 12 +- src/WorkTime.Api/Program.cs | 20 ++++ .../Services/ITimeEntryService.cs | 14 +++ .../Implementation/TimeEntryService.cs | 37 ++++++ src/WorkTime.Api/WorkTime.Api.csproj | 6 + src/WorkTime.Host/Program.cs | 11 ++ .../Properties/launchSettings.json | 4 +- src/WorkTime.Host/WorkTime.Host.csproj | 8 +- .../src/environments/environment.prod.ts | 7 +- .../src/environments/environment.ts | 7 +- src/WorkTime.Mobile/src/models/environment.ts | 4 + .../src/services/backend.service.ts | 43 +++++++ .../src/services/time.service.ts | 5 +- 19 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 WorkTime.Defaults/DataResult.cs create mode 100644 WorkTime.Defaults/Extensions.cs create mode 100644 WorkTime.Defaults/WorkTime.Defaults.csproj create mode 100644 src/WorkTime.Api/Controller/EntryController.cs create mode 100644 src/WorkTime.Api/DatabaseContext.cs create mode 100644 src/WorkTime.Api/Services/ITimeEntryService.cs create mode 100644 src/WorkTime.Api/Services/Implementation/TimeEntryService.cs create mode 100644 src/WorkTime.Mobile/src/models/environment.ts create mode 100644 src/WorkTime.Mobile/src/services/backend.service.ts diff --git a/WorkTime.Defaults/DataResult.cs b/WorkTime.Defaults/DataResult.cs new file mode 100644 index 0000000..8a2bc3b --- /dev/null +++ b/WorkTime.Defaults/DataResult.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace WorkTime.Defaults; + +public class DataResult + where TResult : notnull + where TError : notnull { + public TResult? Content { get; } + public TError? Error { get; } + + public bool IsSuccessful => Error is null && Content is not null; + + public IResult HttpResult => ToHttpResult(this); + + public DataResult(TResult result) { + Content = result; + } + + public DataResult(TError error) { + Error = error; + } + + public static IResult ToHttpResult(DataResult dataResult) { + if (dataResult.Content is bool content) + return content ? Results.Ok() : Results.BadRequest(); + + if (dataResult.IsSuccessful) + return Results.Ok(dataResult.Content); + + if (dataResult.Error is ValidationProblemDetails validationProblem) + return Results.ValidationProblem( + validationProblem.Errors, + validationProblem.Detail, + validationProblem.Instance, + validationProblem.Status, + validationProblem.Title, + validationProblem.Type); + + if (dataResult.Error is ProblemDetails problem) + return Results.Problem(problem); + + if (typeof(TError).IsAssignableTo(typeof(IResult))) + return dataResult.Error as IResult ?? Results.Problem(); + + return Results.Problem(); + } + + public static implicit operator DataResult(TResult result) { + return new DataResult(result); + } + + public static implicit operator DataResult(TError error) { + return new DataResult(error); + } +} + +public class DataResult : DataResult where TResult : notnull { + public DataResult(TResult result) : base(result) { } + public DataResult(Exception error) : base(error) { } + + public static implicit operator DataResult(TResult result) { + return new DataResult(result); + } + + public static implicit operator DataResult(Exception error) { + return new DataResult(error); + } +} + +public class DataResult : DataResult { + public DataResult(bool result) : base(result) { } + public DataResult(Exception error) : base(error) { } + + public static implicit operator DataResult(bool result) { + return new DataResult(result); + } + + public static implicit operator DataResult(Exception error) { + return new DataResult(error); + } +} diff --git a/WorkTime.Defaults/Extensions.cs b/WorkTime.Defaults/Extensions.cs new file mode 100644 index 0000000..c5ea557 --- /dev/null +++ b/WorkTime.Defaults/Extensions.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions { + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { + builder.Logging.AddOpenTelemetry(logging => { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} \ No newline at end of file diff --git a/WorkTime.Defaults/WorkTime.Defaults.csproj b/WorkTime.Defaults/WorkTime.Defaults.csproj new file mode 100644 index 0000000..6a8950a --- /dev/null +++ b/WorkTime.Defaults/WorkTime.Defaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/WorkTime.sln b/WorkTime.sln index 26d83d5..c4066c7 100644 --- a/WorkTime.sln +++ b/WorkTime.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Api", "src\WorkTim EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Host", "src\WorkTime.Host\WorkTime.Host.csproj", "{6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Defaults", "WorkTime.Defaults\WorkTime.Defaults.csproj", "{6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -14,6 +16,7 @@ Global GlobalSection(NestedProjects) = preSolution {63F71A39-70D8-4F22-8006-C345E0CD4A5C} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382} {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382} + {6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {63F71A39-70D8-4F22-8006-C345E0CD4A5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -24,5 +27,9 @@ Global {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}.Release|Any CPU.Build.0 = Release|Any CPU + {6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B97A3FF-6900-4EE9-9FBE-363F8EAA8AE3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/WorkTime.Api/Controller/EntryController.cs b/src/WorkTime.Api/Controller/EntryController.cs new file mode 100644 index 0000000..04c49b1 --- /dev/null +++ b/src/WorkTime.Api/Controller/EntryController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; +using WorkTime.Api.Models; +using WorkTime.Api.Services; + +namespace WorkTime.Api.Controller; + +[ApiController, Route("entries")] +public class EntryController(ITimeEntryService entryService) : ControllerBase { + + [HttpGet("{id:guid}")] + public async Task GetEntries(Guid id) { + var result = await entryService.GetTimeEntries(id); + return result.HttpResult; + } + + [HttpPost("{id:guid}")] + public async Task AddEntry(Guid id, TimeEntryDto entry) { + var result = await entryService.AddTimeEntry(id, entry); + return result.HttpResult; + } + + [HttpDelete("{id:int}")] + public async Task DeleteEntry(int id) { + var result = await entryService.RemoveTimeEntry(id); + return result.HttpResult; + } + +} \ No newline at end of file diff --git a/src/WorkTime.Api/DatabaseContext.cs b/src/WorkTime.Api/DatabaseContext.cs new file mode 100644 index 0000000..c060f9b --- /dev/null +++ b/src/WorkTime.Api/DatabaseContext.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using WorkTime.Api.Models; + +namespace WorkTime.Api; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Entries { get; set; } + +} \ No newline at end of file diff --git a/src/WorkTime.Api/Models/TimeEntry.cs b/src/WorkTime.Api/Models/TimeEntry.cs index d99b1be..76f4123 100644 --- a/src/WorkTime.Api/Models/TimeEntry.cs +++ b/src/WorkTime.Api/Models/TimeEntry.cs @@ -1,7 +1,15 @@ -namespace WorkTime.Api.Models; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; -public class TimeEntry { +namespace WorkTime.Api.Models; + +public class TimeEntry : TimeEntryDto { + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int EntryId { get; set; } public Guid Owner { get; set; } +} + +public class TimeEntryDto { public DateTime RegisteredAt { get; set; } public EntryType Type { get; set; } public bool IsMoba { get; set; } diff --git a/src/WorkTime.Api/Program.cs b/src/WorkTime.Api/Program.cs index d9cfd47..48a21f7 100644 --- a/src/WorkTime.Api/Program.cs +++ b/src/WorkTime.Api/Program.cs @@ -1,15 +1,35 @@ +using Scalar.AspNetCore; +using WorkTime.Api; +using WorkTime.Api.Services; +using WorkTime.Api.Services.Implementation; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.AddServiceDefaults(); +builder.Services.AddControllers(); + +builder.Services.AddProblemDetails(); +builder.Services.AddNpgsql(builder.Configuration.GetConnectionString("data")); +builder.Services.AddScoped(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.MapScalarApiReference(options => { + options.Servers = []; + }); + + await app.Services + .CreateAsyncScope().ServiceProvider + .GetRequiredService() + .Database.EnsureCreatedAsync(); } app.UseHttpsRedirection(); +app.MapControllers(); app.Run(); diff --git a/src/WorkTime.Api/Services/ITimeEntryService.cs b/src/WorkTime.Api/Services/ITimeEntryService.cs new file mode 100644 index 0000000..f863591 --- /dev/null +++ b/src/WorkTime.Api/Services/ITimeEntryService.cs @@ -0,0 +1,14 @@ +using WorkTime.Api.Models; +using WorkTime.Defaults; + +namespace WorkTime.Api.Services; + +public interface ITimeEntryService { + + public Task>> GetTimeEntries(Guid owner); + + public Task AddTimeEntry(Guid owner, TimeEntryDto entry); + + public Task> RemoveTimeEntry(int id); + +} \ No newline at end of file diff --git a/src/WorkTime.Api/Services/Implementation/TimeEntryService.cs b/src/WorkTime.Api/Services/Implementation/TimeEntryService.cs new file mode 100644 index 0000000..1266ae6 --- /dev/null +++ b/src/WorkTime.Api/Services/Implementation/TimeEntryService.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using WorkTime.Api.Models; +using WorkTime.Defaults; + +namespace WorkTime.Api.Services.Implementation; + +public class TimeEntryService(DatabaseContext context) : ITimeEntryService { + public async Task>> GetTimeEntries(Guid owner) { + return await context.Entries + .Where(entry => entry.Owner == owner) + .ToArrayAsync(); + } + + public async Task AddTimeEntry(Guid owner, TimeEntryDto entry) { + var dbEntry = new TimeEntry { + Owner = owner, + RegisteredAt = entry.RegisteredAt, + Type = entry.Type, + IsMoba = entry.IsMoba + }; + + await context.Entries.AddAsync(dbEntry); + await context.SaveChangesAsync(); + return true; + } + + public async Task> RemoveTimeEntry(int id) { + var entry = await context.Entries.FindAsync(id); + + if (entry is null) + return TypedResults.NotFound(); + + context.Entries.Remove(entry); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/WorkTime.Api/WorkTime.Api.csproj b/src/WorkTime.Api/WorkTime.Api.csproj index 126398a..121fbd2 100644 --- a/src/WorkTime.Api/WorkTime.Api.csproj +++ b/src/WorkTime.Api/WorkTime.Api.csproj @@ -8,7 +8,9 @@ + + @@ -17,4 +19,8 @@ + + + + diff --git a/src/WorkTime.Host/Program.cs b/src/WorkTime.Host/Program.cs index b077ec2..47da5c1 100644 --- a/src/WorkTime.Host/Program.cs +++ b/src/WorkTime.Host/Program.cs @@ -1,3 +1,14 @@ var builder = DistributedApplication.CreateBuilder(args); +var db = builder.AddPostgres("db") + .WithDataVolume() + .AddDatabase("data"); + +builder.AddProject("api") + .WithReference(db) + .WaitFor(db); + +builder.AddNpmApp("mobile", "../WorkTime.Mobile") + .WithHttpEndpoint(4200, isProxied: false); + builder.Build().Run(); \ No newline at end of file diff --git a/src/WorkTime.Host/Properties/launchSettings.json b/src/WorkTime.Host/Properties/launchSettings.json index cb941bd..ca30d59 100644 --- a/src/WorkTime.Host/Properties/launchSettings.json +++ b/src/WorkTime.Host/Properties/launchSettings.json @@ -4,7 +4,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:17121;http://localhost:15199", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", @@ -16,7 +16,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "http://localhost:15199", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", diff --git a/src/WorkTime.Host/WorkTime.Host.csproj b/src/WorkTime.Host/WorkTime.Host.csproj index c969fb7..f544c78 100644 --- a/src/WorkTime.Host/WorkTime.Host.csproj +++ b/src/WorkTime.Host/WorkTime.Host.csproj @@ -12,7 +12,13 @@ - + + + + + + + diff --git a/src/WorkTime.Mobile/src/environments/environment.prod.ts b/src/WorkTime.Mobile/src/environments/environment.prod.ts index 3612073..777552a 100644 --- a/src/WorkTime.Mobile/src/environments/environment.prod.ts +++ b/src/WorkTime.Mobile/src/environments/environment.prod.ts @@ -1,3 +1,6 @@ -export const environment = { - production: true +import {Environment} from "../models/environment"; + +export const environment: Environment = { + production: true, + backendUrl: "time.leon-hoppe.de/api/" }; diff --git a/src/WorkTime.Mobile/src/environments/environment.ts b/src/WorkTime.Mobile/src/environments/environment.ts index f56ff47..a0df2ef 100644 --- a/src/WorkTime.Mobile/src/environments/environment.ts +++ b/src/WorkTime.Mobile/src/environments/environment.ts @@ -2,8 +2,11 @@ // `ng build` replaces `environment.ts` with `environment.prod.ts`. // The list of file replacements can be found in `angular.json`. -export const environment = { - production: false +import {Environment} from "../models/environment"; + +export const environment: Environment = { + production: false, + backendUrl: "http://localhost:5295/" }; /* diff --git a/src/WorkTime.Mobile/src/models/environment.ts b/src/WorkTime.Mobile/src/models/environment.ts new file mode 100644 index 0000000..35ec3f8 --- /dev/null +++ b/src/WorkTime.Mobile/src/models/environment.ts @@ -0,0 +1,4 @@ +export interface Environment { + production: boolean; + backendUrl: string; +} diff --git a/src/WorkTime.Mobile/src/services/backend.service.ts b/src/WorkTime.Mobile/src/services/backend.service.ts new file mode 100644 index 0000000..a360e7f --- /dev/null +++ b/src/WorkTime.Mobile/src/services/backend.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import {HttpClient, HttpErrorResponse} from "@angular/common/http"; +import {environment} from "../environments/environment"; +import {firstValueFrom} from "rxjs"; + +export type RequestVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface BackendResult { + content?: TResult; + error?: any; + isSuccessful: boolean; + statusCode: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class BackendService { + constructor(private http: HttpClient) { } + + public async request(verb: RequestVerb, url: string, body?: any): Promise> { + try { + const response = await firstValueFrom(this.http.request(verb, environment.backendUrl + url, { + body: body != undefined ? JSON.stringify(body) : undefined + })); + + return { + content: response, + isSuccessful: true, + statusCode: 200 + } + }catch (e) { + const error = e as HttpErrorResponse; + + return { + error: error, + isSuccessful: false, + statusCode: error.status + } + } + } + +} diff --git a/src/WorkTime.Mobile/src/services/time.service.ts b/src/WorkTime.Mobile/src/services/time.service.ts index a631c57..fdabde9 100644 --- a/src/WorkTime.Mobile/src/services/time.service.ts +++ b/src/WorkTime.Mobile/src/services/time.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@angular/core'; -import {TimeEntry} from "../models/timeEntry"; +import {BackendTimeEntry, TimeEntry} from "../models/timeEntry"; +import {BackendService} from "./backend.service"; @Injectable({ providedIn: 'root' }) export class TimeService { - constructor() { } + constructor(private backend: BackendService) { } public calculateTimespanInMinutes(start: TimeEntry, end: TimeEntry): number { const startSeconds: number = (start.registeredAt.getHours() * 3600) + (start.registeredAt.getMinutes() * 60) + start.registeredAt.getSeconds();