Resolve "Backend" #18

Merged
leon.hoppe merged 2 commits from feature/backend into dev 2025-01-20 18:51:40 +01:00
23 changed files with 444 additions and 2 deletions

View File

@@ -0,0 +1,58 @@
@using Microsoft.AspNetCore.Components.Web;
@using Microsoft.AspNetCore.Components.Routing
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="@Assets["Portfolio.Api.styles.css"]"/>
<link rel="stylesheet" href="@Assets["_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css"]"/>
<ImportMap/>
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet/>
<style>
body {
--body-font: "Segoe UI Variable", "Segoe UI", sans-serif;
font-family: var(--body-font), sans-serif;
font-size: var(--type-ramp-base-font-size);
line-height: var(--type-ramp-base-line-height);
margin: 0;
}
footer {
background: var(--neutral-layer-4);
color: var(--neutral-foreground-rest);
align-items: center;
padding: 10px 10px;
}
footer a {
color: var(--neutral-foreground-rest);
text-decoration: none;
}
footer a:focus {
outline: 1px dashed;
outline-offset: 3px;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData"/>
</Found>
</Router>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Controller;
[ApiController, Route("api/about")]
public class AboutController(IAboutRepository repository) : ControllerBase {
[HttpGet]
public async Task<IActionResult> GetAbout(CancellationToken ct) {
var about = await repository.GetAbout(ct);
return Ok(about);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Controller;
[ApiController, Route("api/projects")]
public class ProjectController(IProjectRepository repository) : ControllerBase {
[HttpGet]
public async Task<IActionResult> GetProjects(CancellationToken ct) {
var projects = await repository.GetProjects(ct);
return Ok(projects);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Controller;
[ApiController, Route("api/technologies")]
public class TechnologyController(ITechnologyRepository repository) : ControllerBase {
[HttpGet]
public async Task<IActionResult> GetTechnologies(CancellationToken ct) {
var technologies = await repository.GetTechnologies(ct);
return Ok(technologies);
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Controller;
[ApiController, Route("api/timeline")]
public class TimelineController(ITimelineRepository repository) : ControllerBase {
[HttpGet("{type}")]
public async Task<IActionResult> GetTimeline(TimelineEntryType type, CancellationToken ct) {
var timeline = await repository.GetTimeline(type, ct);
return Ok(timeline);
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Portfolio.Shared.Models;
namespace Portfolio.Api;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
public DbSet<Project> Projects { get; set; }
public DbSet<Language> Languages { get; set; }
public DbSet<Technology> Technologies { get; set; }
public DbSet<TimelineEntry> Timeline { get; set; }
public DbSet<About> About { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<Project>()
.HasMany(p => p.Languages)
.WithMany()
.UsingEntity("LanguageProject");
}
}

View File

@@ -8,6 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="HopFrame.Web" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
</ItemGroup>

View File

@@ -1,3 +1,10 @@
using HopFrame.Core.Config;
using HopFrame.Web;
using Portfolio.Api;
using Portfolio.Api.Services;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
@@ -5,6 +12,57 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.AddServiceDefaults();
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
builder.AddNpgsqlDbContext<DatabaseContext>("data");
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<ITechnologyRepository, TechnologyRepository>();
builder.Services.AddScoped<ITimelineRepository, TimelineRepository>();
builder.Services.AddScoped<IAboutRepository, AboutRepository>();
builder.Services.AddControllers();
builder.Services.AddHopFrame(options => {
options.DisplayUserInfo(false);
options.AddDbContext<DatabaseContext>(context => {
context.Table<Project>(table => {
var langConfig = table.InnerConfig.Properties
.Single(prop => prop.Name == nameof(Project.Languages));
langConfig
.GetType()!
.GetProperty(nameof(PropertyConfig.IsRelation))!
.SetValue(langConfig, true);
langConfig
.GetType()!
.GetProperty(nameof(PropertyConfig.IsEnumerable))!
.SetValue(langConfig, true);
langConfig
.GetType()!
.GetProperty(nameof(PropertyConfig.IsRequired))!
.SetValue(langConfig, true);
table.Property(p => p.Languages)
.FormatEach<Language>((l, _) => l.Label)
.List(false);
table.Property(p => p.Cover)
.List(false);
table.Property(p => p.Description)
.List(false)
.IsTextArea(true);
table.Property(p => p.SourceCode)
.List(false);
});
});
});
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -12,7 +70,19 @@ if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
}
await using (var scope = app.Services.CreateAsyncScope()) {
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
await db.Database.EnsureCreatedAsync();
}
app.UseHttpsRedirection();
app.MapDefaultEndpoints();
app.MapControllers();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.MapHopFramePages();
app.Run();

View File

@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Services;
internal sealed class AboutRepository(DatabaseContext context) : IAboutRepository {
public async Task<About> GetAbout(CancellationToken ct) {
return await context.About
.SingleAsync(ct);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Services;
internal sealed class ProjectRepository(DatabaseContext context) : IProjectRepository {
public async Task<IEnumerable<Project>> GetProjects(CancellationToken ct) {
return await context.Projects
.Include(p => p.Languages)
.ToArrayAsync(ct);
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Services;
internal sealed class TechnologyRepository(DatabaseContext context) : ITechnologyRepository {
public async Task<IEnumerable<Technology>> GetTechnologies(CancellationToken ct) {
return await context.Technologies.ToArrayAsync(ct);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Portfolio.Shared.Models;
using Portfolio.Shared.Services;
namespace Portfolio.Api.Services;
internal sealed class TimelineRepository(DatabaseContext context) : ITimelineRepository {
public async Task<IEnumerable<TimelineEntry>> GetTimeline(TimelineEntryType type, CancellationToken ct) {
return await context.Timeline
.Where(entry => entry.Type == type)
.ToArrayAsync(ct);
}
}

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.0.0"/>
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,9 +1,17 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.Portfolio_Api>("api");
var postgres = builder.AddPostgres("postgres")
.WithDataVolume("portfolio-postgres");
var db = postgres.AddDatabase("data");
var api = builder.AddProject<Projects.Portfolio_Api>("api")
.WithReference(db)
.WaitFor(db);
builder.AddProject<Projects.Portfolio_Web>("web")
.WithReference(api)
.WaitFor(api);
.WaitFor(api)
.WithExternalHttpEndpoints();
builder.Build().Run();

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Portfolio.Shared.Models;
public sealed class About {
[Key]
public int Id { get; private set; } = 0;
[MaxLength(5000)]
public required string AboutMe { get; set; }
[MaxLength(5000)]
public required string Future { get; set; }
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Portfolio.Shared.Models;
public sealed class Language {
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; init; }
[MaxLength(255)]
public required string Label { get; set; }
[MaxLength(255)]
public required string Identifier { get; set; }
[MaxLength(255)]
public string? Suffix { get; set; }
}

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Portfolio.Shared.Models;
public sealed class Project {
[Key]
public Guid Id { get; init; } = Guid.CreateVersion7();
[MaxLength(255)]
public required string Cover { get; set; }
[MaxLength(255)]
public required string Name { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
[MaxLength(255)]
public required string SourceCode { get; set; }
public bool Featured { get; set; }
public int OrderIndex { get; set; }
public ProjectStatus Status { get; set; } = ProjectStatus.Finished;
[ForeignKey("languages")]
public List<Language> Languages { get; init; } = new();
public DateTime Created { get; init; } = DateTime.Now;
}
public enum ProjectStatus : byte {
Finished = 0,
Canceled = 1,
Paused = 2,
Development = 3
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Portfolio.Shared.Models;
public sealed class Technology {
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; init; }
[MaxLength(255)]
public required string Name { get; set; }
public TechnologyLevel Level { get; set; } = TechnologyLevel.Beginner;
}
public enum TechnologyLevel : byte {
Beginner = 0,
Intermediate = 1,
Professional = 2
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Portfolio.Shared.Models;
public sealed class TimelineEntry {
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; init; }
public DateOnly Date { get; init; }
[MaxLength(1000)]
public required string Description { get; set; }
public bool Featured { get; set; }
public TimelineEntryType Type { get; init; }
}
public enum TimelineEntryType : byte {
Experience = 0,
Carrier = 1
}

View File

@@ -0,0 +1,9 @@
using Portfolio.Shared.Models;
namespace Portfolio.Shared.Services;
public interface IAboutRepository {
Task<About> GetAbout(CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
using Portfolio.Shared.Models;
namespace Portfolio.Shared.Services;
public interface IProjectRepository {
Task<IEnumerable<Project>> GetProjects(CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
using Portfolio.Shared.Models;
namespace Portfolio.Shared.Services;
public interface ITechnologyRepository {
Task<IEnumerable<Technology>> GetTechnologies(CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
using Portfolio.Shared.Models;
namespace Portfolio.Shared.Services;
public interface ITimelineRepository {
Task<IEnumerable<TimelineEntry>> GetTimeline(TimelineEntryType type, CancellationToken ct);
}