From 9a272ec7b13bcf368c1ef1b3ec3ce83a90269c5b Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Thu, 30 Nov 2023 15:17:55 +0200 Subject: [PATCH 01/10] 57 Integrate gql upload mutation --- .gitignore | 7 + .../GraphQL/TeacherWorkoutMutation.cs | 27 ++- .../Types/Payloads/SingleUploadPayloadType.cs | 16 ++ TeacherWorkout.Api/Startup.cs | 13 +- TeacherWorkout.Api/TeacherWorkout.Api.csproj | 1 + ...231130130831_AddFileBlobsTable.Designer.cs | 160 ++++++++++++++++++ .../20231130130831_AddFileBlobsTable.cs | 65 +++++++ .../TeacherWorkoutContextModelSnapshot.cs | 47 ++++- .../Repositories/FileBlobRepository.cs | 22 +++ TeacherWorkout.Data/TeacherWorkoutContext.cs | 6 + .../FileBlobs/IFileBlobRepository.cs | 10 ++ .../FileBlobs/SingleUpload.cs | 47 +++++ TeacherWorkout.Domain/Models/FileBlob.cs | 15 ++ TeacherWorkout.Domain/Models/Image.cs | 3 + .../Models/Inputs/SingleUploadInput.cs | 10 ++ .../Models/Payloads/SingleUploadPayload.cs | 7 + 16 files changed, 447 insertions(+), 9 deletions(-) create mode 100644 TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs create mode 100644 TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs create mode 100644 TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs create mode 100644 TeacherWorkout.Data/Repositories/FileBlobRepository.cs create mode 100644 TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs create mode 100644 TeacherWorkout.Domain/FileBlobs/SingleUpload.cs create mode 100644 TeacherWorkout.Domain/Models/FileBlob.cs create mode 100644 TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs create mode 100644 TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs diff --git a/.gitignore b/.gitignore index d3b8328..5d1db60 100644 --- a/.gitignore +++ b/.gitignore @@ -336,3 +336,10 @@ ASALocalRun/ # Project specific postgres_data .env + +# VSCode +.vscode + +# MacOS +.DS_Store +Thumbs.db diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index 57e6954..d0150fa 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -1,9 +1,13 @@ +using System.IO; using GraphQL; using GraphQL.Types; +using GraphQL.Upload.AspNetCore; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using TeacherWorkout.Api.GraphQL.Resolvers; using TeacherWorkout.Api.GraphQL.Types.Inputs; using TeacherWorkout.Api.GraphQL.Types.Payloads; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Lessons; using TeacherWorkout.Domain.Models.Inputs; using TeacherWorkout.Domain.Models.Payloads; @@ -15,7 +19,8 @@ public class TeacherWorkoutMutation : ObjectGraphType { public TeacherWorkoutMutation(CompleteStep completeStep, CreateTheme createTheme, - UpdateTheme updateTheme) + UpdateTheme updateTheme, + SingleUpload singleUpload) { Name = "Mutation"; @@ -50,6 +55,26 @@ public TeacherWorkoutMutation(CompleteStep completeStep, var input = context.GetArgument("input"); return updateTheme.Execute(input); }); + + + Field("singleUpload") + .Argument(Name = "file") + .Resolve(context => + { + var file = context.GetArgument("file"); + + using var memoryStream = new MemoryStream(); + file.CopyTo(memoryStream); + var fileBytes = memoryStream.ToArray(); + + return singleUpload.Execute(new SingleUploadInput + { + Content = fileBytes, + Mimetype = file.ContentType, + FileName = file.FileName, + }); + }); + } } } \ No newline at end of file diff --git a/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs b/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs new file mode 100644 index 0000000..fcd57c9 --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs @@ -0,0 +1,16 @@ +using System; +using GraphQL.Types; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Api.GraphQL.Types.Payloads +{ + public class SingleUploadPayloadType : ObjectGraphType + { + public SingleUploadPayloadType() + { + Name = "SingleUploadPayload"; + + Field(x => x.FileBlobId).Description("The ID of the created file blob."); + } + } +} diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index f750d6d..13ff6b0 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -42,10 +42,12 @@ public void ConfigureServices(IServiceCollection services) AddRepositories(services, "TeacherWorkout.Data"); services.AddHttpContextAccessor(); - services.AddGraphQL(b => b - .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true) - .AddGraphTypes() - .AddSystemTextJson()); + services + .AddGraphQLUpload() + .AddGraphQL(b => b + .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true) + .AddGraphTypes() + .AddSystemTextJson()); services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("TeacherWorkoutContext"))); @@ -67,7 +69,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW app.UseRouting(); - app.UseGraphQL(); + app.UseGraphQLUpload() + .UseGraphQL(); app.UseGraphQLGraphiQL(); } diff --git a/TeacherWorkout.Api/TeacherWorkout.Api.csproj b/TeacherWorkout.Api/TeacherWorkout.Api.csproj index 962310a..2115bb8 100644 --- a/TeacherWorkout.Api/TeacherWorkout.Api.csproj +++ b/TeacherWorkout.Api/TeacherWorkout.Api.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs new file mode 100644 index 0000000..9763331 --- /dev/null +++ b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs @@ -0,0 +1,160 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeacherWorkout.Data; + +#nullable disable + +namespace TeacherWorkout.Data.Migrations +{ + [DbContext(typeof(TeacherWorkoutContext))] + [Migration("20231130130831_AddFileBlobsTable")] + partial class AddFileBlobsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.FileBlob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Mimetype") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FileBlobs"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FileBlobId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FileBlobId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("ThemeId") + .HasColumnType("text"); + + b.Property("ThumbnailId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ThumbnailId"); + + b.HasIndex("ThemeId", "State"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ThumbnailId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ThumbnailId"); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.HasOne("TeacherWorkout.Domain.Models.FileBlob", "FileBlob") + .WithMany() + .HasForeignKey("FileBlobId"); + + b.Navigation("FileBlob"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => + { + b.HasOne("TeacherWorkout.Domain.Models.Theme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") + .WithMany() + .HasForeignKey("ThumbnailId"); + + b.Navigation("Theme"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => + { + b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") + .WithMany() + .HasForeignKey("ThumbnailId"); + + b.Navigation("Thumbnail"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs new file mode 100644 index 0000000..c8d84c9 --- /dev/null +++ b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeacherWorkout.Data.Migrations +{ + public partial class AddFileBlobsTable : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileBlobId", + table: "Images", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "FileBlobs", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Content = table.Column(type: "bytea", nullable: false), + Mimetype = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + }, + constraints: table => + { + table.PrimaryKey("PK_FileBlobs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Images_FileBlobId", + table: "Images", + column: "FileBlobId"); + + migrationBuilder.AddForeignKey( + name: "FK_Images_FileBlobs_FileBlobId", + table: "Images", + column: "FileBlobId", + principalTable: "FileBlobs", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Images_FileBlobs_FileBlobId", + table: "Images"); + + migrationBuilder.DropIndex( + name: "IX_Images_FileBlobId", + table: "Images"); + + migrationBuilder.DropTable( + name: "FileBlobs"); + + migrationBuilder.DropColumn( + name: "FileBlobId", + table: "Images"); + } + } +} diff --git a/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs b/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs index fdce1f2..8320b8a 100644 --- a/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs +++ b/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs @@ -1,10 +1,13 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using TeacherWorkout.Data; +#nullable disable + namespace TeacherWorkout.Data.Migrations { [DbContext(typeof(TeacherWorkoutContext))] @@ -14,9 +17,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.10") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.FileBlob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Mimetype") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FileBlobs"); + }); modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => { @@ -27,11 +54,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("FileBlobId") + .HasColumnType("text"); + b.Property("Url") .HasColumnType("text"); b.HasKey("Id"); + b.HasIndex("FileBlobId"); + b.ToTable("Images"); }); @@ -87,6 +119,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Themes"); }); + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.HasOne("TeacherWorkout.Domain.Models.FileBlob", "FileBlob") + .WithMany() + .HasForeignKey("FileBlobId"); + + b.Navigation("FileBlob"); + }); + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => { b.HasOne("TeacherWorkout.Domain.Models.Theme", "Theme") diff --git a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs new file mode 100644 index 0000000..8335926 --- /dev/null +++ b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs @@ -0,0 +1,22 @@ +using System.Linq; +using TeacherWorkout.Domain.FileBlobs; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Data.Repositories +{ + public class FileBlobRepository(TeacherWorkoutContext context) : IFileBlobRepository + { + private readonly TeacherWorkoutContext _context = context; + + public void Add(FileBlob fileBlob) + { + _context.FileBlobs.Add(fileBlob); + _context.SaveChanges(); + } + + public FileBlob Find(string id) + { + return _context.FileBlobs.FirstOrDefault(i => i.Id == id); + } + } +} diff --git a/TeacherWorkout.Data/TeacherWorkoutContext.cs b/TeacherWorkout.Data/TeacherWorkoutContext.cs index 72a0195..65a12eb 100644 --- a/TeacherWorkout.Data/TeacherWorkoutContext.cs +++ b/TeacherWorkout.Data/TeacherWorkoutContext.cs @@ -10,6 +10,7 @@ public class TeacherWorkoutContext : DbContext public DbSet Lessons { get; set; } public DbSet Themes { get; set; } public DbSet Images { get; set; } + public DbSet FileBlobs { get; set; } public TeacherWorkoutContext(DbContextOptions options) : base(options) { @@ -45,6 +46,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(l => l.Id) .ValueGeneratedOnAdd() .HasValueGenerator(); + + modelBuilder.Entity() + .Property(l => l.Id) + .ValueGeneratedOnAdd() + .HasValueGenerator(); } } } diff --git a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs new file mode 100644 index 0000000..5f05ddc --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs @@ -0,0 +1,10 @@ +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public interface IFileBlobRepository + { + void Add(FileBlob fileBlob); + FileBlob Find(string id); + } +} \ No newline at end of file diff --git a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs new file mode 100644 index 0000000..0014e6c --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.Models; +using TeacherWorkout.Domain.Models.Inputs; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public class SingleUpload(IFileBlobRepository fileBlobRepository) : IOperation + { + private readonly IFileBlobRepository _fileBlobRepository = fileBlobRepository; + + public SingleUploadPayload Execute(SingleUploadInput input) + { + FileBlob fileBlob = new() + { + Content = input.Content, + Mimetype = input.Mimetype, + Description = input.FileName, + }; + + // Validate extension + string extension = System.IO.Path.GetExtension(input.FileName); + string[] imageExtensions = [".jpg", ".jpeg", ".png", ".gif"]; + if (!imageExtensions.Contains(extension.ToLower())) + { + throw new ValidationException("Invalid image extension"); + } + + // Validate content type + string[] imageContentTypes = ["image/jpeg", "image/png", "image/gif"]; + if (!imageContentTypes.Contains(fileBlob.Mimetype.ToLower())) + { + throw new ValidationException("Invalid image content type"); + } + + _fileBlobRepository.Add(fileBlob); + + return new SingleUploadPayload + { + FileBlobId = fileBlob.Id + }; + } + + } +} \ No newline at end of file diff --git a/TeacherWorkout.Domain/Models/FileBlob.cs b/TeacherWorkout.Domain/Models/FileBlob.cs new file mode 100644 index 0000000..5300fa2 --- /dev/null +++ b/TeacherWorkout.Domain/Models/FileBlob.cs @@ -0,0 +1,15 @@ + +using System; +using TeacherWorkout.Domain.Common; + +namespace TeacherWorkout.Domain.Models +{ + public class FileBlob : IIdentifiable + { + public string Id { get; set; } + public byte[] Content { get; set; } + public string Mimetype { get; set; } + public string Description { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Models/Image.cs b/TeacherWorkout.Domain/Models/Image.cs index c8110e3..92ab08c 100644 --- a/TeacherWorkout.Domain/Models/Image.cs +++ b/TeacherWorkout.Domain/Models/Image.cs @@ -1,3 +1,4 @@ +using System.IO; using TeacherWorkout.Domain.Common; namespace TeacherWorkout.Domain.Models @@ -9,5 +10,7 @@ public class Image : IIdentifiable public string Description { get; set; } public string Url { get; set; } + + public FileBlob FileBlob { get; set; } } } \ No newline at end of file diff --git a/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs b/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs new file mode 100644 index 0000000..5a024ef --- /dev/null +++ b/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs @@ -0,0 +1,10 @@ +namespace TeacherWorkout.Domain.Models.Inputs +{ + public class SingleUploadInput + { + public string FileName { get; set; } + public string Mimetype { get; set; } + public string Encoding { get; set; } + public byte[] Content { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs b/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs new file mode 100644 index 0000000..e03417c --- /dev/null +++ b/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs @@ -0,0 +1,7 @@ +namespace TeacherWorkout.Domain.Models.Payloads +{ + public class SingleUploadPayload + { + public string FileBlobId { get; set; } + } +} From becf395961e0d0ba797f9109bc71a7e7b29c69a9 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Thu, 30 Nov 2023 17:19:52 +0200 Subject: [PATCH 02/10] Fix createdAt field --- .../Migrations/20231130130831_AddFileBlobsTable.cs | 2 +- TeacherWorkout.Domain/FileBlobs/SingleUpload.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs index c8d84c9..ed5242e 100644 --- a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs +++ b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs @@ -23,7 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Content = table.Column(type: "bytea", nullable: false), Mimetype = table.Column(type: "text", nullable: false), Description = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), }, constraints: table => { diff --git a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs index 0014e6c..67740c1 100644 --- a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs +++ b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using TeacherWorkout.Domain.Common; @@ -18,6 +19,7 @@ public SingleUploadPayload Execute(SingleUploadInput input) Content = input.Content, Mimetype = input.Mimetype, Description = input.FileName, + CreatedAt = DateTime.UtcNow }; // Validate extension From 149365ae109fddd8ccbcfa896a7eca2d955bfca5 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Sat, 2 Dec 2023 11:07:36 +0200 Subject: [PATCH 03/10] Add non null to singleUpload mutation --- TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index d0150fa..96d0388 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -58,7 +58,7 @@ public TeacherWorkoutMutation(CompleteStep completeStep, Field("singleUpload") - .Argument(Name = "file") + .Argument>(Name = "file") .Resolve(context => { var file = context.GetArgument("file"); From 21477c8e975fa9f7219cb7d21dac3bb65d551882 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Sat, 2 Dec 2023 17:17:42 +0200 Subject: [PATCH 04/10] add endpoints to get single file and recent files --- .../Controllers/FileController.cs | 25 ++++++++++++ .../GraphQL/TeacherWorkoutMutation.cs | 8 +++- .../GraphQL/TeacherWorkoutQuery.cs | 9 ++++- .../GraphQL/Types/FileBlobType.cs | 16 ++++++++ TeacherWorkout.Api/GraphQL/Types/ImageType.cs | 3 +- .../Types/Inputs/ThemeCreateInputType.cs | 5 ++- .../Repositories/FileBlobRepository.cs | 10 +++++ .../FileBlobs/GetFileBlobs.cs | 16 ++++++++ .../FileBlobs/IFileBlobRepository.cs | 2 + TeacherWorkout.Domain/FileBlobs/ImageUtils.cs | 8 ++++ .../FileBlobs/SingleUpload.cs | 13 +++--- TeacherWorkout.Domain/Models/Image.cs | 1 + .../Models/Inputs/ThemeCreateInput.cs | 1 + TeacherWorkout.Domain/Themes/CreateTheme.cs | 40 ++++++++++++------- 14 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 TeacherWorkout.Api/Controllers/FileController.cs create mode 100644 TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs create mode 100644 TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs create mode 100644 TeacherWorkout.Domain/FileBlobs/ImageUtils.cs diff --git a/TeacherWorkout.Api/Controllers/FileController.cs b/TeacherWorkout.Api/Controllers/FileController.cs new file mode 100644 index 0000000..7b3dc5b --- /dev/null +++ b/TeacherWorkout.Api/Controllers/FileController.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using TeacherWorkout.Data; + +namespace TeacherWorkout.Api.Controllers +{ + [Route("file")] + [ApiController] + public class FileController(TeacherWorkoutContext context) : ControllerBase + { + private readonly TeacherWorkoutContext _context = context; + + [HttpGet("{id}")] + public async Task GetImage(string id) + { + var fileBlob = await _context.FileBlobs.FindAsync(id); + if (fileBlob == null) + { + return NotFound(); + } + + return File(fileBlob.Content, fileBlob.Mimetype); + } + } +} diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index 96d0388..e544600 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.IO; using GraphQL; using GraphQL.Types; @@ -63,6 +64,11 @@ public TeacherWorkoutMutation(CompleteStep completeStep, { var file = context.GetArgument("file"); + if (file.Length > 5 * 1024 * 1024) + { + throw new ValidationException("File size exceeds the limit of 5MB."); + } + using var memoryStream = new MemoryStream(); file.CopyTo(memoryStream); var fileBytes = memoryStream.ToArray(); @@ -77,4 +83,4 @@ public TeacherWorkoutMutation(CompleteStep completeStep, } } -} \ No newline at end of file +} diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs index 3efd832..3112c16 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs @@ -1,7 +1,9 @@ +using GraphQL; using GraphQL.Types; using TeacherWorkout.Api.GraphQL.Types; using TeacherWorkout.Api.GraphQL.Utils; using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Lessons; using TeacherWorkout.Domain.Themes; @@ -12,7 +14,8 @@ public class TeacherWorkoutQuery : ObjectGraphType public TeacherWorkoutQuery(GetThemes getThemes, GetLessons getLessons, GetLessonStatuses getLessonStatuses, - GetStep getStep) + GetStep getStep, + GetFileBlobs getFileBlobs) { Name = "Query"; @@ -35,6 +38,10 @@ public TeacherWorkoutQuery(GetThemes getThemes, Field>>("lessonStatuses") .Argument>>>(Name = "lessonIds", Description = "Id's of leassons") .Resolve(context => getLessonStatuses.Execute(context.ToInput())); + + Field>>("recentImageUploads") + .Argument>("limit", "The number of recent images to return.") + .Resolve(context => getFileBlobs.Execute(context.GetArgument("limit"))); } } } diff --git a/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs b/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs new file mode 100644 index 0000000..5edd139 --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs @@ -0,0 +1,16 @@ +using GraphQL.Types; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Api.GraphQL.Types +{ + public class FileBlobType : ObjectGraphType + { + public FileBlobType() + { + Name = "FileBlob"; + + Field(x => x.Id, nullable: false).Description("The unique identifier of the file blob."); + Field(x => x.CreatedAt, nullable: false).Description("The creation time of the file blob."); + } + } +} diff --git a/TeacherWorkout.Api/GraphQL/Types/ImageType.cs b/TeacherWorkout.Api/GraphQL/Types/ImageType.cs index 9f53ff9..e5d4b56 100644 --- a/TeacherWorkout.Api/GraphQL/Types/ImageType.cs +++ b/TeacherWorkout.Api/GraphQL/Types/ImageType.cs @@ -9,8 +9,9 @@ public ImageType() { Name = "Image"; - Field(x => x.Url, true).Description("URL to the image."); + Field(x => x.Url, true).Description("URL to the image. If null, use FileBlob ID to generate an URL: /file/"); Field(x => x.Description, true).Description("Image description for accessibility."); + Field(x => x.FileBlobId, true).Description("Reference to local file. If null, use Url property."); } } } \ No newline at end of file diff --git a/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs index 77689dc..d8ce245 100644 --- a/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs +++ b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs @@ -9,8 +9,11 @@ public ThemeCreateInputType() { Name = "ThemeCreateInput"; - Field(x => x.ThumbnailId, type: typeof(IdGraphType)); Field(x => x.Title); + Field(x => x.FileBlobId, true, type: typeof(IdGraphType)) + .Description("Id of uploaded image blob (precedes and overwrites ThumbnailId)"); + Field(x => x.ThumbnailId, true, type: typeof(IdGraphType)) + .Description("Id of existing thumbnail"); } } } diff --git a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs index 8335926..0fde723 100644 --- a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs +++ b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Models; @@ -18,5 +19,14 @@ public FileBlob Find(string id) { return _context.FileBlobs.FirstOrDefault(i => i.Id == id); } + + public List FindRecent(string[] mimetypes, int? limit) + { + return _context.FileBlobs + .Where(i => mimetypes.Contains(i.Mimetype)) + .OrderByDescending(i => i.CreatedAt) + .Take(limit ?? 5) + .ToList(); + } } } diff --git a/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs b/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs new file mode 100644 index 0000000..7e73205 --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public class GetFileBlobs(IFileBlobRepository repository) : IOperation> + { + private readonly IFileBlobRepository _repository = repository; + + public List Execute(int limit) + { + return _repository.FindRecent(ImageUtils.ContentTypes, limit); + } + } +} diff --git a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs index 5f05ddc..51c01fe 100644 --- a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs +++ b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using TeacherWorkout.Domain.Models; namespace TeacherWorkout.Domain.FileBlobs @@ -6,5 +7,6 @@ public interface IFileBlobRepository { void Add(FileBlob fileBlob); FileBlob Find(string id); + List FindRecent(string[] mimetypes, int? limit); } } \ No newline at end of file diff --git a/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs b/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs new file mode 100644 index 0000000..734d2cf --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs @@ -0,0 +1,8 @@ +namespace TeacherWorkout.Domain.FileBlobs +{ + public static class ImageUtils + { + public static readonly string[] Extensions = [".jpg", ".jpeg", ".png", ".gif"]; + public static readonly string[] ContentTypes = ["image/jpeg", "image/png", "image/gif"]; + } +} diff --git a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs index 67740c1..46bad5a 100644 --- a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs +++ b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs @@ -17,22 +17,20 @@ public SingleUploadPayload Execute(SingleUploadInput input) FileBlob fileBlob = new() { Content = input.Content, - Mimetype = input.Mimetype, + Mimetype = input.Mimetype.ToLower(), Description = input.FileName, CreatedAt = DateTime.UtcNow }; // Validate extension string extension = System.IO.Path.GetExtension(input.FileName); - string[] imageExtensions = [".jpg", ".jpeg", ".png", ".gif"]; - if (!imageExtensions.Contains(extension.ToLower())) + if (!ImageUtils.Extensions.Contains(extension.ToLower())) { throw new ValidationException("Invalid image extension"); } // Validate content type - string[] imageContentTypes = ["image/jpeg", "image/png", "image/gif"]; - if (!imageContentTypes.Contains(fileBlob.Mimetype.ToLower())) + if (!ImageUtils.ContentTypes.Contains(fileBlob.Mimetype)) { throw new ValidationException("Invalid image content type"); } @@ -44,6 +42,5 @@ public SingleUploadPayload Execute(SingleUploadInput input) FileBlobId = fileBlob.Id }; } - - } -} \ No newline at end of file + } +} diff --git a/TeacherWorkout.Domain/Models/Image.cs b/TeacherWorkout.Domain/Models/Image.cs index 92ab08c..db459e4 100644 --- a/TeacherWorkout.Domain/Models/Image.cs +++ b/TeacherWorkout.Domain/Models/Image.cs @@ -11,6 +11,7 @@ public class Image : IIdentifiable public string Url { get; set; } + public string FileBlobId { get; set; } public FileBlob FileBlob { get; set; } } } \ No newline at end of file diff --git a/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs index 3912466..cd8b1ab 100644 --- a/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs +++ b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs @@ -5,5 +5,6 @@ public class ThemeCreateInput public string Title { get; set; } public string ThumbnailId { get; set; } + public string FileBlobId { get; set; } } } diff --git a/TeacherWorkout.Domain/Themes/CreateTheme.cs b/TeacherWorkout.Domain/Themes/CreateTheme.cs index 80a6159..f8c4a1a 100644 --- a/TeacherWorkout.Domain/Themes/CreateTheme.cs +++ b/TeacherWorkout.Domain/Themes/CreateTheme.cs @@ -1,4 +1,6 @@ +using System.ComponentModel.DataAnnotations; using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Images; using TeacherWorkout.Domain.Models; using TeacherWorkout.Domain.Models.Inputs; @@ -6,32 +8,42 @@ namespace TeacherWorkout.Domain.Themes { - public class CreateTheme : IOperation + public class CreateTheme(IThemeRepository themeRepository, + IImageRepository imageRepository, + IFileBlobRepository fileBlobRepository) : IOperation { - private readonly IThemeRepository _themeRepository; - private readonly IImageRepository _imageRepository; + private readonly IThemeRepository _themeRepository = themeRepository; + private readonly IImageRepository _imageRepository = imageRepository; + private readonly IFileBlobRepository _ifileBlobRepository = fileBlobRepository; - public CreateTheme(IThemeRepository themeRepository, - IImageRepository imageRepository) - { - _themeRepository = themeRepository; - _imageRepository = imageRepository; - } - public ThemeCreatePayload Execute(ThemeCreateInput input) { var theme = new Theme { - Title = input.Title, - Thumbnail = _imageRepository.Find(input.ThumbnailId) + Title = input.Title }; + if (!string.IsNullOrEmpty(input.FileBlobId)) + { + var fileBlob = _ifileBlobRepository.Find(input.FileBlobId) ?? throw new ValidationException("FileBlob ID not found"); + var thumbnail = new Image + { + Description = fileBlob.Description, + FileBlob = fileBlob + }; + theme.Thumbnail = thumbnail; + } + else + { + theme.Thumbnail = _imageRepository.Find(input.ThumbnailId) ?? throw new ValidationException("Thumbnail ID not found"); + } + _themeRepository.Insert(theme); - + return new ThemeCreatePayload { Theme = theme }; } } -} \ No newline at end of file +} From 6ad36de30a2c662efb3d088184cca2e42126f5f8 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Mon, 4 Dec 2023 11:47:39 +0200 Subject: [PATCH 05/10] Add fileBlob cleanup with Hangfire --- TeacherWorkout.Api/Startup.cs | 21 ++++++++++++++++++- TeacherWorkout.Api/TeacherWorkout.Api.csproj | 2 ++ .../Repositories/FileBlobRepository.cs | 16 +++++++++++++- .../FileBlobs/IFileBlobRepository.cs | 1 + 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index 13ff6b0..8e04d1d 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -2,6 +2,8 @@ using System.Linq; using GraphQL; using GraphQL.Types; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -11,6 +13,7 @@ using TeacherWorkout.Api.GraphQL; using TeacherWorkout.Data; using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.FileBlobs; namespace TeacherWorkout.Api { @@ -51,10 +54,22 @@ public void ConfigureServices(IServiceCollection services) services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("TeacherWorkoutContext"))); + + services.AddHangfire(configuration => + { + configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_170); + configuration.UseSimpleAssemblyNameTypeSerializer(); + configuration.UseRecommendedSerializerSettings(); + + // Initialize JobStorage + configuration.UsePostgreSqlStorage(c => + c.UseNpgsqlConnection(Configuration.GetConnectionString("TeacherWorkoutContext"))); + }); + services.AddHangfireServer(config => config.WorkerCount = 1); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherWorkoutContext db) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherWorkoutContext db, IServiceProvider serviceProvider) { app.UseCors(); @@ -72,6 +87,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW app.UseGraphQLUpload() .UseGraphQL(); app.UseGraphQLGraphiQL(); + + var fileBlobRepository = serviceProvider.GetRequiredService(); + RecurringJob.AddOrUpdate("DeleteOldFileBlobs", + () => fileBlobRepository.DeleteOldEntries(), Cron.Minutely); } private static void AddOperations(IServiceCollection services) diff --git a/TeacherWorkout.Api/TeacherWorkout.Api.csproj b/TeacherWorkout.Api/TeacherWorkout.Api.csproj index 2115bb8..2a5013b 100644 --- a/TeacherWorkout.Api/TeacherWorkout.Api.csproj +++ b/TeacherWorkout.Api/TeacherWorkout.Api.csproj @@ -8,6 +8,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs index 0fde723..aa2e76c 100644 --- a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs +++ b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs @@ -1,13 +1,17 @@ +using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Models; namespace TeacherWorkout.Data.Repositories { - public class FileBlobRepository(TeacherWorkoutContext context) : IFileBlobRepository + public class FileBlobRepository(TeacherWorkoutContext context, ILogger customLogger) : IFileBlobRepository { private readonly TeacherWorkoutContext _context = context; + private readonly ILogger _logger = customLogger; + public void Add(FileBlob fileBlob) { @@ -28,5 +32,15 @@ public List FindRecent(string[] mimetypes, int? limit) .Take(limit ?? 5) .ToList(); } + + public void DeleteOldEntries() + { + var cutoffDate = DateTime.Now.AddDays(-1).ToUniversalTime(); + var oldEntries = _context.FileBlobs + .Where(fb => fb.CreatedAt < cutoffDate && !_context.Images.Any(i => i.FileBlobId == fb.Id)); + _logger.LogInformation("Deleting {EntryCount} old file blobs", oldEntries.Count()); + _context.FileBlobs.RemoveRange(oldEntries); + _context.SaveChanges(); + } } } diff --git a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs index 51c01fe..4184aa8 100644 --- a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs +++ b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs @@ -8,5 +8,6 @@ public interface IFileBlobRepository void Add(FileBlob fileBlob); FileBlob Find(string id); List FindRecent(string[] mimetypes, int? limit); + void DeleteOldEntries(); } } \ No newline at end of file From 9bc4f581d833e344e362ee46f29c2d99d45d6ab7 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Mon, 4 Dec 2023 11:48:51 +0200 Subject: [PATCH 06/10] Fix daily blob delete schedule --- TeacherWorkout.Api/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index 8e04d1d..c66b899 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -90,7 +90,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW var fileBlobRepository = serviceProvider.GetRequiredService(); RecurringJob.AddOrUpdate("DeleteOldFileBlobs", - () => fileBlobRepository.DeleteOldEntries(), Cron.Minutely); + () => fileBlobRepository.DeleteOldEntries(), Cron.Daily); } private static void AddOperations(IServiceCollection services) From 7a80fca9a9f034aec0217ef02a04dc15e05e65db Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Mon, 4 Dec 2023 15:27:13 +0200 Subject: [PATCH 07/10] missing rest controller init; FileBlob seed data --- TeacherWorkout.Api/Startup.cs | 2 ++ .../TeacherWorkoutSeeder.cs | 34 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index c66b899..a38012b 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -44,6 +44,7 @@ public void ConfigureServices(IServiceCollection services) AddOperations(services); AddRepositories(services, "TeacherWorkout.Data"); + services.AddControllers(); services.AddHttpContextAccessor(); services .AddGraphQLUpload() @@ -83,6 +84,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW } app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); app.UseGraphQLUpload() .UseGraphQL(); diff --git a/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs b/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs index 178484d..5a0dda1 100644 --- a/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs +++ b/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs @@ -12,8 +12,8 @@ public class TeacherWorkoutSeeder { private readonly TeacherWorkoutContext _context; - private readonly List _images = new() - { + private readonly List _images = + [ new() { Id = "1", @@ -86,13 +86,12 @@ public class TeacherWorkoutSeeder { Id = "11", Description = "Beautiful dog photo", - Url = - "https://commons.wikimedia.org/wiki/Category:Quality_images_of_dogs#/media/File:Canis_lupus_PO.jpg" + FileBlobId = "FileBlob_1", } - }; + ]; - private readonly List _themes = new() - { + private readonly List _themes = + [ new() { Id = "1", @@ -159,10 +158,10 @@ public class TeacherWorkoutSeeder Title = "Accusamus et iusto", ThumbnailId = "11" } - }; + ]; - private readonly List _lessons = new() - { + private readonly List _lessons = + [ new() { Id = "1", @@ -295,7 +294,19 @@ public class TeacherWorkoutSeeder Unit = DurationUnit.Minutes } } - }; + ]; + + private readonly List _fileBlobs = + [ + new() + { + Id = "FileBlob_1", + Content = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), + Mimetype = "image/png", + Description = "Tiny image", + CreatedAt = DateTime.Now.ToUniversalTime() + } + ]; public TeacherWorkoutSeeder(TeacherWorkoutContext context) { @@ -315,6 +326,7 @@ public async Task Seed() await _context.AddRangeAsync(_images); await _context.AddRangeAsync(_themes); await _context.AddRangeAsync(_lessons); + await _context.AddRangeAsync(_fileBlobs); await _context.SaveChangesAsync(); } From 60dac77c77a5ce7819b1caed7694bf802ae2e00d Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Mon, 4 Dec 2023 15:58:28 +0200 Subject: [PATCH 08/10] Fix PR comment about configurable max_file_size --- TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs | 11 +++++++---- TeacherWorkout.Api/appsettings.json | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index e544600..736396b 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -5,6 +5,7 @@ using GraphQL.Upload.AspNetCore; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using TeacherWorkout.Api.GraphQL.Resolvers; using TeacherWorkout.Api.GraphQL.Types.Inputs; using TeacherWorkout.Api.GraphQL.Types.Payloads; @@ -21,7 +22,8 @@ public class TeacherWorkoutMutation : ObjectGraphType public TeacherWorkoutMutation(CompleteStep completeStep, CreateTheme createTheme, UpdateTheme updateTheme, - SingleUpload singleUpload) + SingleUpload singleUpload, + IConfiguration configuration) { Name = "Mutation"; @@ -63,10 +65,11 @@ public TeacherWorkoutMutation(CompleteStep completeStep, .Resolve(context => { var file = context.GetArgument("file"); - - if (file.Length > 5 * 1024 * 1024) + + var maxFileSizeMb = configuration.GetValue("TeacherWorkout:MaxFileSizeMb", 5); + if (file.Length > maxFileSizeMb * 1024 * 1024) { - throw new ValidationException("File size exceeds the limit of 5MB."); + throw new ValidationException($"File size exceeds the limit of {maxFileSizeMb}MB."); } using var memoryStream = new MemoryStream(); diff --git a/TeacherWorkout.Api/appsettings.json b/TeacherWorkout.Api/appsettings.json index 397ab92..5c450c8 100644 --- a/TeacherWorkout.Api/appsettings.json +++ b/TeacherWorkout.Api/appsettings.json @@ -10,5 +10,8 @@ "ConnectionStrings": { //connection string for release (docker-compose) enviroment "TeacherWorkoutContext": "Server=postgres;Port=5432;Database=teacher_workout;User Id=docker;Password=docker;" + }, + "TeacherWorkout": { + "MaxFileSizeMb": 5 } } From 70790072c91d579868dc38c27427f20e8af49197 Mon Sep 17 00:00:00 2001 From: NicolaeS Date: Thu, 7 Dec 2023 17:59:46 +0200 Subject: [PATCH 09/10] Fix PR comments: fix test, refactor recurringJobs --- .../Jobs/Config/DeleteOldFileBlobsConfig.cs | 7 +++++ .../Jobs/Config/RecurringJobConfig.cs | 8 +++++ .../Jobs/DeleteOldFileBlobsJob.cs | 20 ++++++++++++ .../Jobs/Interfaces/IDeleteOldFileBlobsJob.cs | 10 ++++++ TeacherWorkout.Api/Startup.cs | 21 ++++++++++--- TeacherWorkout.Api/appsettings.json | 9 +++++- .../Repositories/FileBlobRepository.cs | 4 +-- .../FileBlobs/IFileBlobRepository.cs | 2 +- TeacherWorkout.Domain/Themes/CreateTheme.cs | 4 +-- TeacherWorkout.Specs/Features/Themes.feature | 6 ++-- .../Features/Themes.feature.cs | 4 +-- .../GraphQL/Mutation/SingleUpload.graphql | 5 +++ .../Steps/ThemeStepDefinitions.cs | 31 +++++++++++++------ .../TeacherWorkoutApiClient.cs | 30 ++++++++++++++++-- 14 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs create mode 100644 TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs create mode 100644 TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs create mode 100644 TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs create mode 100644 TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql diff --git a/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs b/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs new file mode 100644 index 0000000..5714226 --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs @@ -0,0 +1,7 @@ +namespace TeacherWorkout.Api.Jobs.Config +{ + public record DeleteOldFileBlobsConfig : RecurringJobConfig + { + public int DaysInThePast { get; set; } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs b/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs new file mode 100644 index 0000000..1f2a02a --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs @@ -0,0 +1,8 @@ +namespace TeacherWorkout.Api.Jobs.Config +{ + public record RecurringJobConfig + { + public bool IsEnabled { get; set; } + public string CronExpression { get; set; } + } +} diff --git a/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs new file mode 100644 index 0000000..8eb77fa --- /dev/null +++ b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs @@ -0,0 +1,20 @@ +using DeUrgenta.RecurringJobs.Jobs.Config; +using Microsoft.Extensions.Options; +using TeacherWorkout.Api.Jobs.Interfaces; +using TeacherWorkout.Domain.FileBlobs; + +namespace TeacherWorkout.Api.Jobs +{ + + public class DeleteOldFileBlobsJob(IFileBlobRepository repository, IOptions config) : IDeleteOldFileBlobsJob + { + private readonly IFileBlobRepository _repository = repository; + private readonly DeleteOldFileBlobsConfig _config = config.Value; + + public void Run() + { + ; + _repository.DeleteOldEntries(_config.DaysInThePast); + } + } +} diff --git a/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs b/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs new file mode 100644 index 0000000..cb261c5 --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TeacherWorkout.Api.Jobs.Interfaces +{ + public interface IDeleteOldFileBlobsJob + { + void Run(); + } +} \ No newline at end of file diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index a38012b..498de55 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -11,9 +11,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using TeacherWorkout.Api.GraphQL; +using TeacherWorkout.Api.Jobs; +using TeacherWorkout.Api.Jobs.Config; +using TeacherWorkout.Api.Jobs.Interfaces; using TeacherWorkout.Data; using TeacherWorkout.Domain.Common; -using TeacherWorkout.Domain.FileBlobs; namespace TeacherWorkout.Api { @@ -67,6 +69,9 @@ public void ConfigureServices(IServiceCollection services) c.UseNpgsqlConnection(Configuration.GetConnectionString("TeacherWorkoutContext"))); }); services.AddHangfireServer(config => config.WorkerCount = 1); + + services.Configure(Configuration.GetSection("TeacherWorkout:RecurringJobs:DeleteOldFileBlobs")); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -90,9 +95,17 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW .UseGraphQL(); app.UseGraphQLGraphiQL(); - var fileBlobRepository = serviceProvider.GetRequiredService(); - RecurringJob.AddOrUpdate("DeleteOldFileBlobs", - () => fileBlobRepository.DeleteOldEntries(), Cron.Daily); + var deleteOldFileBlobsConfig = Configuration.GetSection("TeacherWorkout:RecurringJobs:DeleteOldFileBlobs") + .Get(); + if (deleteOldFileBlobsConfig.IsEnabled) + { + RecurringJob.AddOrUpdate( + nameof(DeleteOldFileBlobsJob), + job => job.Run(), + deleteOldFileBlobsConfig.CronExpression, + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc } + ); + } } private static void AddOperations(IServiceCollection services) diff --git a/TeacherWorkout.Api/appsettings.json b/TeacherWorkout.Api/appsettings.json index 5c450c8..f6dc121 100644 --- a/TeacherWorkout.Api/appsettings.json +++ b/TeacherWorkout.Api/appsettings.json @@ -12,6 +12,13 @@ "TeacherWorkoutContext": "Server=postgres;Port=5432;Database=teacher_workout;User Id=docker;Password=docker;" }, "TeacherWorkout": { - "MaxFileSizeMb": 5 + "MaxFileSizeMb": 5, + "RecurringJobs": { + "DeleteOldFileBlobs": { + "IsEnabled": "true", + "CronExpression": "0 0 * * *", + "DaysInThePast": "1" + } + } } } diff --git a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs index aa2e76c..6b84479 100644 --- a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs +++ b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs @@ -33,9 +33,9 @@ public List FindRecent(string[] mimetypes, int? limit) .ToList(); } - public void DeleteOldEntries() + public void DeleteOldEntries(int daysInThePast) { - var cutoffDate = DateTime.Now.AddDays(-1).ToUniversalTime(); + var cutoffDate = DateTime.Now.AddDays(-daysInThePast).ToUniversalTime(); var oldEntries = _context.FileBlobs .Where(fb => fb.CreatedAt < cutoffDate && !_context.Images.Any(i => i.FileBlobId == fb.Id)); _logger.LogInformation("Deleting {EntryCount} old file blobs", oldEntries.Count()); diff --git a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs index 4184aa8..5a92459 100644 --- a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs +++ b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs @@ -8,6 +8,6 @@ public interface IFileBlobRepository void Add(FileBlob fileBlob); FileBlob Find(string id); List FindRecent(string[] mimetypes, int? limit); - void DeleteOldEntries(); + void DeleteOldEntries(int daysInThePast); } } \ No newline at end of file diff --git a/TeacherWorkout.Domain/Themes/CreateTheme.cs b/TeacherWorkout.Domain/Themes/CreateTheme.cs index f8c4a1a..0b073c9 100644 --- a/TeacherWorkout.Domain/Themes/CreateTheme.cs +++ b/TeacherWorkout.Domain/Themes/CreateTheme.cs @@ -14,7 +14,7 @@ public class CreateTheme(IThemeRepository themeRepository, { private readonly IThemeRepository _themeRepository = themeRepository; private readonly IImageRepository _imageRepository = imageRepository; - private readonly IFileBlobRepository _ifileBlobRepository = fileBlobRepository; + private readonly IFileBlobRepository _fileBlobRepository = fileBlobRepository; public ThemeCreatePayload Execute(ThemeCreateInput input) { @@ -25,7 +25,7 @@ public ThemeCreatePayload Execute(ThemeCreateInput input) if (!string.IsNullOrEmpty(input.FileBlobId)) { - var fileBlob = _ifileBlobRepository.Find(input.FileBlobId) ?? throw new ValidationException("FileBlob ID not found"); + var fileBlob = _fileBlobRepository.Find(input.FileBlobId) ?? throw new ValidationException("FileBlob ID not found"); var thumbnail = new Image { Description = fileBlob.Description, diff --git a/TeacherWorkout.Specs/Features/Themes.feature b/TeacherWorkout.Specs/Features/Themes.feature index 71c6a60..7c9bd6e 100644 --- a/TeacherWorkout.Specs/Features/Themes.feature +++ b/TeacherWorkout.Specs/Features/Themes.feature @@ -1,15 +1,15 @@ Feature: Themes As a user I want to be able to list themes - + Scenario: Admin user can create a theme Given Ion is an admin - When Ion creates a theme + When Ion creates a theme with image Then the theme was created successfully Scenario: Anonymous user can list themes Given Ion is an admin And Vasile is an anonymous user - And Ion creates a theme + And Ion creates a theme with image When Vasile requests themes Then Vasile receives the theme diff --git a/TeacherWorkout.Specs/Features/Themes.feature.cs b/TeacherWorkout.Specs/Features/Themes.feature.cs index cb4828e..91f6802 100644 --- a/TeacherWorkout.Specs/Features/Themes.feature.cs +++ b/TeacherWorkout.Specs/Features/Themes.feature.cs @@ -102,7 +102,7 @@ public void AdminUserCanCreateATheme() testRunner.Given("Ion is an admin", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line hidden #line 7 - testRunner.When("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); + testRunner.When("Ion creates a theme with image", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line hidden #line 8 testRunner.Then("the theme was created successfully", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); @@ -136,7 +136,7 @@ public void AnonymousUserCanListThemes() testRunner.And("Vasile is an anonymous user", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); #line hidden #line 13 - testRunner.And("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); + testRunner.And("Ion creates a theme with image", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); #line hidden #line 14 testRunner.When("Vasile requests themes", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); diff --git a/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql b/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql new file mode 100644 index 0000000..df46baf --- /dev/null +++ b/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql @@ -0,0 +1,5 @@ +mutation SingleUpload($file: Upload!) { + singleUpload(file: $file) { + fileBlobId + } +} diff --git a/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs index c9453e5..6e13548 100644 --- a/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs +++ b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs @@ -1,25 +1,36 @@ +using System; using System.Threading.Tasks; using FluentAssertions; +using Newtonsoft.Json.Linq; +using SpecFlow.Internal.Json; +using TeacherWorkout.Domain.Models; using TeacherWorkout.Specs.Extensions; using TechTalk.SpecFlow; +using Xunit.Abstractions; namespace TeacherWorkout.Specs.Steps { [Binding] - public class ThemeStepDefinitions + public class ThemeStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; + private readonly ScenarioContext _scenarioContext = scenarioContext; - public ThemeStepDefinitions(ScenarioContext scenarioContext) + [Given(@"Ion creates a theme with image")] + [When(@"Ion creates a theme with image")] + public async Task GivenIonCreatesAThemeWithImage() { - _scenarioContext = scenarioContext; - } + FileBlob imageFile = new() + { + Id = "FileBlob_1", + Content = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), + Mimetype = "image/png", + Description = "tiny_image.png", + CreatedAt = DateTime.Now.ToUniversalTime() + }; + string uploadJson = await ((TeacherWorkoutApiClient)_scenarioContext["Ion"]).UploadImage(imageFile); + string fileBlobId = JObject.Parse(uploadJson)["data"]["singleUpload"]["fileBlobId"].ToString(); - [Given(@"Ion creates a theme")] - [When(@"Ion creates a theme")] - public async Task GivenIonCreatesATheme() - { - _scenarioContext["theme-create-response"] = await ((TeacherWorkoutApiClient) _scenarioContext["Ion"]).ThemeCreateAsync(); + _scenarioContext["theme-create-response"] = await ((TeacherWorkoutApiClient) _scenarioContext["Ion"]).ThemeCreateAsync(fileBlobId); } [When(@"Vasile requests themes")] diff --git a/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs index 3a0f3c2..1b77fe2 100644 --- a/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs +++ b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs @@ -1,7 +1,10 @@ +using System; +using System.Globalization; using System.IO; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using TeacherWorkout.Domain.Models; using TeacherWorkout.Specs.Extensions; namespace TeacherWorkout.Specs @@ -15,7 +18,8 @@ enum Queries enum Mutations { - ThemeCreate + ThemeCreate, + SingleUpload } private readonly HttpClient _client; @@ -25,18 +29,38 @@ public TeacherWorkoutApiClient(HttpClient client) _client = client; } + public async Task UploadImage(FileBlob imageFile) + { + + var operations = new StringContent(new {query = GraphQL("Mutation", "SingleUpload"), variables = new {file = (string)null}}.ToJson(), Encoding.UTF8, "application/json"); + var map = new StringContent("{\"0\": [\"variables.file\"]}", Encoding.UTF8, "application/json"); + var imageBytes = new ByteArrayContent(imageFile.Content); + imageBytes.Headers.Add("Content-Type", imageFile.Mimetype); + + var multipartContent = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)) + { + { operations, "operations" }, + { map, "map" }, + { imageBytes, "0", imageFile.Description } + }; + var response = await _client.PostAsync("http://localhost/graphql", multipartContent); + + return await response.Content.ReadAsStringAsync(); + } + public async Task ThemesAsync() { return await SendRequest(QueryFor(Queries.Themes), new {}); } - public async Task ThemeCreateAsync() + public async Task ThemeCreateAsync(string fileBlobId) { return await SendRequest(MutationFor(Mutations.ThemeCreate), new { input = new { - title = "foo" + title = "foo", + fileBlobId, } }); } From cc1f219d7f826c55e8150bd3679141209aefbe33 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Fri, 8 Dec 2023 09:30:10 +0200 Subject: [PATCH 10/10] Update DeleteOldFileBlobsJob.cs --- TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs index 8eb77fa..b6d1ac8 100644 --- a/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs +++ b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs @@ -1,7 +1,7 @@ -using DeUrgenta.RecurringJobs.Jobs.Config; using Microsoft.Extensions.Options; using TeacherWorkout.Api.Jobs.Interfaces; using TeacherWorkout.Domain.FileBlobs; +using TeacherWorkout.Api.Jobs.Config; namespace TeacherWorkout.Api.Jobs {