From ea01e0046baaabef7031f89eb7fe0daee9a04452 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sat, 21 Dec 2024 19:38:19 +0100 Subject: [PATCH 1/6] email changes --- Emails/EmailBase.cs | 3 + Emails/wwwroot/output.css | 443 +++++++++++++++++++++----------- Services/Email/IEmailService.cs | 3 +- Services/Email/SMTPService.cs | 3 + 4 files changed, 303 insertions(+), 149 deletions(-) diff --git a/Emails/EmailBase.cs b/Emails/EmailBase.cs index 77c3709..3f8d477 100644 --- a/Emails/EmailBase.cs +++ b/Emails/EmailBase.cs @@ -2,6 +2,7 @@ using BlazorTemplater; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Mvc; using NejCommon.Services.Email; +using System.Net.Mail; using System.Runtime.CompilerServices; namespace NejCommon.Emails @@ -33,5 +34,7 @@ namespace NejCommon.Emails { return "DEV: " + this.ToString(); } + + public virtual List GetAttachments() => new List(); } } diff --git a/Emails/wwwroot/output.css b/Emails/wwwroot/output.css index 86a9ef8..6ce011d 100644 --- a/Emails/wwwroot/output.css +++ b/Emails/wwwroot/output.css @@ -1,113 +1,5 @@ -*, ::before, ::after { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - -::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - /* -! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com +! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */ /* @@ -139,12 +31,9 @@ 3. Use a more readable tab size. 4. Use the user's configured `sans` font-family by default. 5. Use the user's configured `sans` font-feature-settings by default. -6. Use the user's configured `sans` font-variation-settings by default. -7. Disable tap highlights on iOS */ -html, -:host { +html { line-height: 1.5; /* 1 */ -webkit-text-size-adjust: 100%; @@ -154,14 +43,10 @@ html, -o-tab-size: 4; tab-size: 4; /* 3 */ - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: "Noto Sans", "Roboto", "Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ font-feature-settings: normal; /* 5 */ - font-variation-settings: normal; - /* 6 */ - -webkit-tap-highlight-color: transparent; - /* 7 */ } /* @@ -233,10 +118,8 @@ strong { } /* -1. Use the user's configured `mono` font-family by default. -2. Use the user's configured `mono` font-feature-settings by default. -3. Use the user's configured `mono` font-variation-settings by default. -4. Correct the odd `em` font sizing in all browsers. +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. */ code, @@ -245,12 +128,8 @@ samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */ - font-feature-settings: normal; - /* 2 */ - font-variation-settings: normal; - /* 3 */ font-size: 1em; - /* 4 */ + /* 2 */ } /* @@ -309,18 +188,12 @@ select, textarea { font-family: inherit; /* 1 */ - font-feature-settings: inherit; - /* 1 */ - font-variation-settings: inherit; - /* 1 */ font-size: 100%; /* 1 */ font-weight: inherit; /* 1 */ line-height: inherit; /* 1 */ - letter-spacing: inherit; - /* 1 */ color: inherit; /* 1 */ margin: 0; @@ -344,9 +217,9 @@ select { */ button, -input:where([type='button']), -input:where([type='reset']), -input:where([type='submit']) { +[type='button'], +[type='reset'], +[type='submit'] { -webkit-appearance: button; /* 1 */ background-color: transparent; @@ -465,14 +338,6 @@ menu { padding: 0; } -/* -Reset default styling for dialogs. -*/ - -dialog { - padding: 0; -} - /* Prevent resizing textareas horizontally by default. */ @@ -554,24 +419,306 @@ video { display: none; } -.visible { - visibility: visible; +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; } .static { position: static; } +.absolute { + position: absolute; +} + +.ml-2 { + margin-left: 0.5rem; +} + .inline { display: inline; } +.flex { + display: flex; +} + .table { display: table; } -.hidden { - display: none; +.contents { + display: contents; +} + +.h-full { + height: 100%; +} + +.h-16 { + height: 4rem; +} + +.h-48 { + height: 12rem; +} + +.w-full { + width: 100%; +} + +.w-16 { + width: 4rem; +} + +.w-48 { + width: 12rem; +} + +.flex-grow { + flex-grow: 1; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-start { + justify-content: flex-start; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.self-end { + align-self: flex-end; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded { + border-radius: 0.25rem; +} + +.border-4 { + border-width: 4px; +} + +.border { + border-width: 1px; +} + +.bg-primary { + --tw-bg-opacity: 1; + background-color: rgb(var(--primary) / var(--tw-bg-opacity)); +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pb-2 { + padding-bottom: 0.5rem; +} + +.pl-8 { + padding-left: 2rem; +} + +.pr-8 { + padding-right: 2rem; +} + +.pr-4 { + padding-right: 1rem; +} + +.pt-1 { + padding-top: 0.25rem; +} + +.text-left { + text-align: left; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.font-semibold { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +.text-secondary { + --tw-text-opacity: 1; + color: rgb(var(--secondary-text) / var(--tw-text-opacity)); +} + +.text-primary { + --tw-text-opacity: 1; + color: rgb(var(--primary-text) / var(--tw-text-opacity)); +} + +.line-through { + text-decoration-line: line-through; } .filter { diff --git a/Services/Email/IEmailService.cs b/Services/Email/IEmailService.cs index 2a77095..638a354 100644 --- a/Services/Email/IEmailService.cs +++ b/Services/Email/IEmailService.cs @@ -14,10 +14,11 @@ public interface IEmailService var obj = Activator.CreateInstance(); obj.Data = data; var subject = obj.GetSubject(); + var atts = obj.GetAttachments(); //invoke the GetHTML function asynchronously and wait for the result var html = await (Task)func.Invoke(null, new object[] { data }); - await SendEmailAsync(recipient, subject, html, attachments); + await SendEmailAsync(recipient, subject, html, attachments == null ? atts : atts.Concat(attachments).ToList()); } } \ No newline at end of file diff --git a/Services/Email/SMTPService.cs b/Services/Email/SMTPService.cs index fbdc91c..0be8c78 100644 --- a/Services/Email/SMTPService.cs +++ b/Services/Email/SMTPService.cs @@ -38,6 +38,7 @@ public class SMTPService : IEmailService using (var client = new MailKit.Net.Smtp.SmtpClient()) { + await Task.Delay(100); await client.ConnectAsync(_settings.Host, _settings.Port); // Note: only needed if the SMTP server requires authentication @@ -45,6 +46,8 @@ public class SMTPService : IEmailService await client.SendAsync(mess); await client.DisconnectAsync(true); + + await Task.Delay(100); using (var imap = new MailKit.Net.Imap.ImapClient()) { From c0d49b8e24a9c792950e9c2224133c026ccc93af Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sun, 5 Jan 2025 23:17:07 +0100 Subject: [PATCH 2/6] update some stuff for db --- AutoScan.cs | 28 ++++++++++++++++++++++++++++ Models/CommonDbContext.cs | 1 + 2 files changed, 29 insertions(+) diff --git a/AutoScan.cs b/AutoScan.cs index 952783c..2d05798 100644 --- a/AutoScan.cs +++ b/AutoScan.cs @@ -1,3 +1,7 @@ +using Microsoft.EntityFrameworkCore; +using NejAccountingAPI.Models; +using Quartz; + namespace NejCommon; public interface IScopedService @@ -15,6 +19,11 @@ public interface ITransientService public interface IBackgroundService { +} +public interface IRecurringService : IJob +{ + public static virtual JobKey? JobKey { get; } + public static abstract string Cron { get; } } public interface ISettings { @@ -23,6 +32,25 @@ public interface ISettings public static class AutoScan { + public static void RegisterJobs(this IServiceCollection collection, IServiceCollectionQuartzConfigurator conf) + { + var jobs = typeof(IRecurringService).Assembly.GetTypes().Where(x => x.IsAssignableTo(typeof(IRecurringService)) && x != typeof(IRecurringService)).ToList(); + + foreach (var job in jobs) + { + Console.WriteLine("Testing job: " + job.Name); + var jobKey = job.GetProperty("JobKey")?.GetValue(null) as JobKey ?? new JobKey(job.FullName); + var cron = job.GetProperty("Cron")!.GetValue(null) as string; + + if (string.IsNullOrEmpty(cron) || jobKey == null) + { + throw new Exception("Could not register job: " + job.Name); + } + Console.WriteLine("Adding job: " + jobKey.Name + " with cron: " + cron); + + conf.AddJob(job, jobKey).AddTrigger(x => x.ForJob(jobKey).WithCronSchedule(cron).StartNow()); + } + } public static void RegisterServices(this IServiceCollection collection) { collection.Scan(scan => scan diff --git a/Models/CommonDbContext.cs b/Models/CommonDbContext.cs index 5c9780a..ccc8766 100644 --- a/Models/CommonDbContext.cs +++ b/Models/CommonDbContext.cs @@ -157,6 +157,7 @@ namespace NejCommon.Models; config?.Invoke(entity); this.Add(entity); + this.ChangeTracker.DetectChanges(); return entity; } From ba100f0010ad76656588cf16e6ddae5f4382e7e9 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sat, 11 Jan 2025 19:00:51 +0100 Subject: [PATCH 3/6] add polymnorphism --- Controllers/AutoChildController.cs | 12 +- Controllers/AutoController.cs | 55 ++++-- Controllers/TypedResultsPolyfill.cs | 197 +++++++++++-------- Models/CommonDbContext.cs | 285 +++++++++++++++------------- Utils/Extensions.cs | 14 ++ 5 files changed, 323 insertions(+), 240 deletions(-) diff --git a/Controllers/AutoChildController.cs b/Controllers/AutoChildController.cs index bc36567..2469f1c 100644 --- a/Controllers/AutoChildController.cs +++ b/Controllers/AutoChildController.cs @@ -17,8 +17,8 @@ namespace NejCommon.Controllers { public abstract class AutoChildController : AutoChildController where TType : class, new() - where TRequest : IAutomappedAttribute, new() - where TResponse : IAutomappedAttribute, new() + where TRequest : class, IAutomappedAttribute, new() + where TResponse : class, IAutomappedAttribute, new() { public AutoChildController(CommonDbContext appDb, IServiceProvider providers) : base(appDb, providers) { @@ -34,9 +34,9 @@ namespace NejCommon.Controllers [ApiController] public abstract class AutoChildController : ControllerBase where TType : class, new() - where TGetResponse : IAutomappedAttribute, new() - where TUpdateRequest : IAutomappedAttribute, new() - where TUpdateResponse : IAutomappedAttribute, new() + where TGetResponse : class, IAutomappedAttribute, new() + where TUpdateRequest : class, IAutomappedAttribute, new() + where TUpdateResponse : class, IAutomappedAttribute, new() { protected readonly CommonDbContext db; protected readonly IServiceProvider providers; @@ -99,8 +99,6 @@ namespace NejCommon.Controllers }*/ body.ApplyTo(providers, entity); - var dat = new TUpdateResponse().ApplyFrom(providers, entity); - var res = await db.ApiSaveChangesAsyncOk(providers, entity); //use the private constructor thru reflection diff --git a/Controllers/AutoController.cs b/Controllers/AutoController.cs index 29b9096..0f99023 100644 --- a/Controllers/AutoController.cs +++ b/Controllers/AutoController.cs @@ -17,9 +17,9 @@ namespace NejCommon.Controllers { public abstract class AutoController : AutoController - where TType : class, new() - where TRequest : IAutomappedAttribute, new() - where TResponse : IAutomappedAttribute, new() + where TType : class + where TRequest : class, IAutomappedAttribute, new() + where TResponse : class, IAutomappedAttribute, new() { public AutoController(CommonDbContext appDb, IServiceProvider providers) : base(appDb, providers) { @@ -34,13 +34,13 @@ namespace NejCommon.Controllers /// The response type [ApiController] public abstract partial class AutoController : AutoGetterController - where TType : class, new() - where TGetAllResponse : IAutomappedAttribute, new() - where TCreateRequest : IAutomappedAttribute, new() - where TCreateResponse : IAutomappedAttribute, new() - where TGetResponse : IAutomappedAttribute, new() - where TUpdateRequest : IAutomappedAttribute, new() - where TUpdateResponse : IAutomappedAttribute, new() + where TType : class + where TGetAllResponse : class, IAutomappedAttribute, new() + where TCreateRequest : class, IAutomappedAttribute, new() + where TCreateResponse : class, IAutomappedAttribute, new() + where TGetResponse : class, IAutomappedAttribute, new() + where TUpdateRequest : class, IAutomappedAttribute, new() + where TUpdateResponse : class, IAutomappedAttribute, new() { public AutoController(CommonDbContext appDb, IServiceProvider providers) : base(appDb, providers) @@ -59,6 +59,12 @@ namespace NejCommon.Controllers props.SetValue(entity, comp); return entity; } + protected override IAutomappedAttribute GetResponseType(RequestedResponseType type, TType entity) => type switch + { + RequestedResponseType.Create => new TCreateResponse(), + RequestedResponseType.Update => new TUpdateResponse(), + _ => base.GetResponseType(type, entity), + }; /// /// Creates the @@ -69,15 +75,16 @@ namespace NejCommon.Controllers [HttpPost] public virtual async Task, CreatedAtRoute>> Create([FromServices] TOwner company, [FromBody] TCreateRequest body) { - var entity = db.Create(); + var type = body.GetSourceType(); + var entity = (TType)db.Create(type); entity = AssociateWithParent(entity, company); await db.AddAsync(entity); - body.ApplyTo(providers, entity); + body.ApplyTo(providers, (object)entity); - return await db.ApiSaveChangesAsyncCreate(providers, entity); + return await db.ApiSaveChangesAsyncCreate(providers, entity, true, (TCreateResponse)GetResponseType(RequestedResponseType.Create, entity)); } /// @@ -106,11 +113,11 @@ namespace NejCommon.Controllers [Route("{id}/")] public virtual async Task, Ok>> Update([FromServices][ModelBinder(Name = "id")] TType entity, [FromBody] TUpdateRequest body) { - body.ApplyTo(providers, entity); + body.ApplyTo(providers, (object)entity); var dat = new TUpdateResponse().ApplyFrom(providers, entity); - var res = await db.ApiSaveChangesAsyncOk(providers, entity); + var res = await db.ApiSaveChangesAsyncOk(providers, entity, true, (TUpdateResponse)GetResponseType(RequestedResponseType.Update, entity)); //use the private constructor thru reflection var ctor = typeof(Results, Ok>).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)[0]; @@ -119,7 +126,7 @@ namespace NejCommon.Controllers } [ApiController] public abstract class AutoGetterController : ControllerBase - where TType : class, new() + where TType : class where TGetAllResponse : IAutomappedAttribute, new() where TGetResponse : IAutomappedAttribute, new() { @@ -134,6 +141,18 @@ namespace NejCommon.Controllers protected abstract IQueryable GetQuery(TOwner comp); + public enum RequestedResponseType + { + Get, + Create, + Update + } + protected virtual IAutomappedAttribute GetResponseType(RequestedResponseType type, TType entity) => type switch + { + RequestedResponseType.Get => new TGetResponse(), + _ => throw new InvalidOperationException("Not implemented"), + }; + protected virtual IQueryable ApplyDefaultOrdering(IQueryable query) { return query; @@ -169,8 +188,8 @@ namespace NejCommon.Controllers [Route("{id}/")] public virtual async Task>> Get([FromServices][ModelBinder(Name = "id")] TType entity) { - var dat = new TGetResponse().ApplyFrom(providers, entity); - return TypedResults.Ok(dat); + var dat = GetResponseType(RequestedResponseType.Get, entity).ApplyFrom(providers, entity); + return TypedResults.Ok((TGetResponse)dat); } } } diff --git a/Controllers/TypedResultsPolyfill.cs b/Controllers/TypedResultsPolyfill.cs index 0154ced..5bb51a9 100644 --- a/Controllers/TypedResultsPolyfill.cs +++ b/Controllers/TypedResultsPolyfill.cs @@ -1,7 +1,14 @@ using System.Diagnostics.Eventing.Reader; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -9,100 +16,132 @@ namespace NejCommon.Controllers { public class TypedResultsMetadataProvider : IOperationFilter { - public void Apply(OpenApiOperation operation, OperationFilterContext context) + private readonly Lazy _contentTypes; + + /// + /// Constructor to inject services + /// + /// MVC options to define response content types + public TypedResultsMetadataProvider(IOptions mvc) { - var responseType = context.MethodInfo.ReturnType; - //Console.WriteLine(context.MethodInfo.DeclaringType.Name); - //Console.WriteLine(context.MethodInfo.Name); - //Console.WriteLine(responseType); - var t = IsSubclassOfRawGeneric(typeof(Microsoft.AspNetCore.Http.HttpResults.Results<,>), responseType); - if (t == null) + _contentTypes = new Lazy(() => { - return; - } - - var parArg = t.GetGenericArguments(); - if (operation.Responses.ContainsKey("200")) - operation.Responses.Remove("200"); - - foreach (var arg in parArg) - { - if (arg == typeof(NotFound)) + var apiResponseTypes = new List(); + if (mvc.Value == null) { - operation.Responses.Add("404", new OpenApiResponse { Description = "Not found" }); - } - else if (arg == typeof(Ok)) - { - operation.Responses.Add("200", new OpenApiResponse { Description = "Success" }); - } - else if (IsSubclassOfRawGeneric(typeof(Ok<>), arg) != null) - { - - var okArg = IsSubclassOfRawGeneric(typeof(Ok<>), arg).GetGenericArguments()[0]; - // Console.WriteLine("Adding: " + okArg); - - //get or generate the schema - var schema = context.SchemaGenerator.GenerateSchema(okArg, context.SchemaRepository); - operation.Responses.Add("200", new OpenApiResponse { Description = "Success", Content = { { "application/json", new OpenApiMediaType { Schema = schema } } } }); - - } - else if (arg == typeof(CreatedAtRoute)) - { - operation.Responses.Add("201", new OpenApiResponse { Description = "Success" }); - } - else if (IsSubclassOfRawGeneric(typeof(CreatedAtRoute<>), arg) != null) - { - if (operation.Responses.ContainsKey("201")) - operation.Responses.Remove("201"); - - var okArg = IsSubclassOfRawGeneric(typeof(CreatedAtRoute<>), arg).GetGenericArguments()[0]; - // Console.WriteLine("Adding: " + okArg); - - //get or generate the schema - var schema = context.SchemaGenerator.GenerateSchema(okArg, context.SchemaRepository); - operation.Responses.Add("201", new OpenApiResponse { Description = "Success", Content = { { "application/json", new OpenApiMediaType { Schema = schema } } } }); - } - else if (arg == typeof(BadRequest)) - { - operation.Responses.Add("400", new OpenApiResponse { Description = "There was an error" }); - } - else if (IsSubclassOfRawGeneric(typeof(BadRequest<>), arg) != null) - { - if (operation.Responses.ContainsKey("400")) - operation.Responses.Remove("400"); - - var okArg = IsSubclassOfRawGeneric(typeof(BadRequest<>), arg).GetGenericArguments()[0]; - // Console.WriteLine("Adding: " + okArg); - - //get or generate the schema - var schema = context.SchemaGenerator.GenerateSchema(okArg, context.SchemaRepository); - operation.Responses.Add("400", new OpenApiResponse { Description = "There was an error", Content = { { "application/json", new OpenApiMediaType { Schema = schema } } } }); - } - else if (arg == typeof(FileStreamHttpResult)){ - operation.Responses.Add("200", new OpenApiResponse { Description = "Success", Content = { { "application/octet-stream", new OpenApiMediaType { Schema = new OpenApiSchema { Type = "string", Format = "binary" } } } } }); + apiResponseTypes.Add("application/json"); } else { - Console.WriteLine("Unknown type: " + arg); + var jsonApplicationType = mvc.Value.FormatterMappings.GetMediaTypeMappingForFormat("json"); + if (jsonApplicationType != null) + apiResponseTypes.Add(jsonApplicationType); + var xmlApplicationType = mvc.Value.FormatterMappings.GetMediaTypeMappingForFormat("xml"); + if (xmlApplicationType != null) + apiResponseTypes.Add(xmlApplicationType); } + return apiResponseTypes.ToArray(); + }); + } + + void IOperationFilter.Apply(OpenApiOperation operation, OperationFilterContext context) + { + + if (!IsControllerAction(context)) return; + + var actionReturnType = UnwrapTask(context.MethodInfo.ReturnType); + if (!IsHttpResults(actionReturnType)) return; + + if (typeof(IEndpointMetadataProvider).IsAssignableFrom(actionReturnType)) + { + var populateMetadataMethod = actionReturnType.GetMethod("Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider.PopulateMetadata", BindingFlags.Static | BindingFlags.NonPublic); + if (populateMetadataMethod == null) return; + + var endpointBuilder = new MetadataEndpointBuilder(); + populateMetadataMethod.Invoke(null, new object[] { context.MethodInfo, endpointBuilder }); + + var responseTypes = endpointBuilder.Metadata.Cast().ToList(); + if (!responseTypes.Any()) return; + operation.Responses.Clear(); + foreach (var responseType in responseTypes) + { + var statusCode = responseType.StatusCode.ToString(); + var oar = new OpenApiResponse { Description = GetResponseDescription(statusCode) }; + + if (responseType.Type != null && responseType.Type != typeof(void)) + { + var schema = context.SchemaGenerator.GenerateSchema(responseType.Type, context.SchemaRepository); + foreach (var contentType in _contentTypes.Value) + { + oar.Content.Add(contentType, new OpenApiMediaType { Schema = schema }); + } + } + + operation.Responses.Add(statusCode, oar); + } + } + else if (actionReturnType == typeof(UnauthorizedHttpResult)) + { + operation.Responses.Clear(); + operation.Responses.Add("401", new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(401) }); + } } - static Type? IsSubclassOfRawGeneric(Type generic, Type toCheck) + private static bool IsControllerAction(OperationFilterContext context) + => context.ApiDescription.ActionDescriptor is ControllerActionDescriptor; + + private static bool IsHttpResults(Type type) + => type.Namespace == "Microsoft.AspNetCore.Http.HttpResults"; + + private static Type UnwrapTask(Type type) { - while (toCheck != null && toCheck != typeof(object)) + if (type.IsGenericType) { - //if Task is used, we need to check the underlying type - var realTypeNoTask = toCheck.IsGenericType && toCheck.GetGenericTypeDefinition() == typeof(Task<>) ? toCheck.GetGenericArguments()[0] : toCheck; - var cur = realTypeNoTask.IsGenericType ? realTypeNoTask.GetGenericTypeDefinition() : realTypeNoTask; - //Console.WriteLine(cur); - if (generic == cur) + var genericType = type.GetGenericTypeDefinition(); + if (genericType == typeof(Task<>) || genericType == typeof(ValueTask<>)) { - return realTypeNoTask; + return type.GetGenericArguments()[0]; } - toCheck = toCheck.BaseType; } - return null; + return type; + } + + private static string? GetResponseDescription(string statusCode) + => ResponseDescriptionMap + .FirstOrDefault(entry => Regex.IsMatch(statusCode, entry.Key)) + .Value; + + private static readonly IReadOnlyCollection> ResponseDescriptionMap = new[] + { + new KeyValuePair("1\\d{2}", "Information"), + + new KeyValuePair("201", "Created"), + new KeyValuePair("202", "Accepted"), + new KeyValuePair("204", "No Content"), + new KeyValuePair("2\\d{2}", "Success"), + + new KeyValuePair("304", "Not Modified"), + new KeyValuePair("3\\d{2}", "Redirect"), + + new KeyValuePair("400", "Bad Request"), + new KeyValuePair("401", "Unauthorized"), + new KeyValuePair("403", "Forbidden"), + new KeyValuePair("404", "Not Found"), + new KeyValuePair("405", "Method Not Allowed"), + new KeyValuePair("406", "Not Acceptable"), + new KeyValuePair("408", "Request Timeout"), + new KeyValuePair("409", "Conflict"), + new KeyValuePair("429", "Too Many Requests"), + new KeyValuePair("4\\d{2}", "Client Error"), + + new KeyValuePair("5\\d{2}", "Server Error"), + new KeyValuePair("default", "Error") + }; + + private sealed class MetadataEndpointBuilder : EndpointBuilder + { + public override Endpoint Build() => throw new NotImplementedException(); } } } \ No newline at end of file diff --git a/Models/CommonDbContext.cs b/Models/CommonDbContext.cs index ccc8766..26d557b 100644 --- a/Models/CommonDbContext.cs +++ b/Models/CommonDbContext.cs @@ -11,158 +11,171 @@ using Microsoft.EntityFrameworkCore; namespace NejCommon.Models; - public abstract class CommonDbContext : DbContext +public abstract class CommonDbContext : DbContext +{ + public CommonDbContext() : base() { - public CommonDbContext() : base() + } + public CommonDbContext(DbContextOptions options) + : base(options) + { + } + + public abstract CommonDbContext CreateCopy(); + + public async Task ApiSaveChangesAsync() + { + try { + await SaveChangesAsync(); + return true; } - public CommonDbContext(DbContextOptions options) - : base(options) + catch (Exception ex) { + Console.WriteLine("Error saving db: " + ex.Message); + Console.WriteLine(ex.StackTrace); + return false; + } + } + + public static BadRequest SaveError = TypedResults.BadRequest(new Error + { + Message = "Error saving data to database" + }); + + public async Task, T1>> ApiSaveChangesAsync(T1 value) where T1 : IResult + { + var res = await ApiSaveChangesAsync(); + + if (res) + return value; + else + return SaveError; + } + public async Task, CreatedAtRoute>> ApiSaveChangesAsyncCreate(IServiceProvider providers, T1 value, bool apply = true, T2? response = null) where T2 : class, IAutomappedAttribute, new() + { + if(response == null) + response = new T2(); + if (!apply) + return TypedResults.CreatedAtRoute(response.ApplyFrom(providers, value)); + + var res = await ApiSaveChangesAsync(); + + if (res) + return TypedResults.CreatedAtRoute(response.ApplyFrom(providers, value)); + else + return SaveError; + } + public async Task, Ok>> ApiSaveChangesAsyncOk(IServiceProvider providers, T1 value, bool apply = true, T2? response = null) where T2 : class, IAutomappedAttribute, new() + { + if(response == null) + response = new T2(); + if (!apply) + return TypedResults.Ok(response.ApplyFrom(providers, value)); + + var res = await ApiSaveChangesAsync(); + + if (res) + return TypedResults.Ok(response.ApplyFrom(providers, value)); + else + return SaveError; + } + public async Task FindOrCreateAsync(Expression> predicate, Func factory) where T : class + { + var entity = ChangeTracker.Entries().Select(x => x.Entity).FirstOrDefault(predicate.Compile()); + + //find in change tracker + if (entity != null && Entry(entity).State == EntityState.Deleted) + { + Entry(entity).State = EntityState.Modified; } - public abstract CommonDbContext CreateCopy(); - - public async Task ApiSaveChangesAsync() + //find in currentDb + if (entity == null) { - try - { - await SaveChangesAsync(); - return true; - } - catch (Exception ex) - { - Console.WriteLine("Error saving db: " + ex.Message); - Console.WriteLine(ex.StackTrace); - return false; - } + entity = await Set().FirstOrDefaultAsync(predicate); } - public static BadRequest SaveError = TypedResults.BadRequest(new Error + //find in up-to-date db + if (entity == null) { - Message = "Error saving data to database" - }); + var newAppDB = CreateCopy(); - public async Task, T1>> ApiSaveChangesAsync(T1 value) where T1 : IResult - { - var res = await ApiSaveChangesAsync(); + entity = await newAppDB.Set().FirstOrDefaultAsync(predicate); - if (res) - return value; - else - return SaveError; - } - public async Task, CreatedAtRoute>> ApiSaveChangesAsyncCreate(IServiceProvider providers, T1 value, bool apply = true) where T2 : IAutomappedAttribute, new() - { - if (!apply) - return TypedResults.CreatedAtRoute(new T2().ApplyFrom(providers, value)); - - var res = await ApiSaveChangesAsync(); - - if (res) - return TypedResults.CreatedAtRoute(new T2().ApplyFrom(providers, value)); - else - return SaveError; - } - public async Task, Ok>> ApiSaveChangesAsyncOk(IServiceProvider providers, T1 value, bool apply = true) where T2 : IAutomappedAttribute, new() - { - if (!apply) - return TypedResults.Ok(new T2().ApplyFrom(providers, value)); - - var res = await ApiSaveChangesAsync(); - - if (res) - return TypedResults.Ok(new T2().ApplyFrom(providers, value)); - else - return SaveError; - } - public async Task FindOrCreateAsync(Expression> predicate, Func factory) where T : class - { - var entity = ChangeTracker.Entries().Select(x => x.Entity).FirstOrDefault(predicate.Compile()); - - //find in change tracker - if (entity != null && Entry(entity).State == EntityState.Deleted) - { - Entry(entity).State = EntityState.Modified; - } - - //find in currentDb - if (entity == null) - { - entity = await Set().FirstOrDefaultAsync(predicate); - } - - //find in up-to-date db - if (entity == null) - { - var newAppDB = CreateCopy(); - - entity = await newAppDB.Set().FirstOrDefaultAsync(predicate); - - //track the entity if it's not null and not already being tracked - if (entity != null && this.Entry(entity).State == EntityState.Detached) - Attach(entity); - } - - //create if not found - if (entity == null) - { - var newEntity = factory(); - await this.AddAsync(newEntity); - entity = newEntity; - } - - return entity; - } - public T FindOrCreate(Expression> predicate, Func factory) where T : class - { - var entity = ChangeTracker.Entries().Where(e => e.State != EntityState.Deleted).Select(x => x.Entity).FirstOrDefault(predicate.Compile()); - - //find in change tracker - if (entity != null && Entry(entity).State == EntityState.Deleted) - { - Entry(entity).State = EntityState.Modified; - } - - //find in currentDb - if (entity == null) - { - entity = Set().FirstOrDefault(predicate); - } - - //find in up-to-date db - if (entity == null) - { - var newAppDB = CreateCopy(); - - entity = newAppDB.Set().FirstOrDefault(predicate); - if (entity != null) - Attach(entity); - } - - //create if not found - if (entity == null) - { - var newEntity = factory(); - this.Add(newEntity); - entity = newEntity; - } - - return entity; + //track the entity if it's not null and not already being tracked + if (entity != null && this.Entry(entity).State == EntityState.Detached) + Attach(entity); } - public T Create(Action config = null, params object[] constructorArguments) + //create if not found + if (entity == null) { - var entity = this.CreateProxy(constructorArguments); - - config?.Invoke(entity); - this.Add(entity); - this.ChangeTracker.DetectChanges(); - return entity; + var newEntity = factory(); + await this.AddAsync(newEntity); + entity = newEntity; } - public void ApplyRelationships() + return entity; + } + public T FindOrCreate(Expression> predicate, Func factory) where T : class + { + var entity = ChangeTracker.Entries().Where(e => e.State != EntityState.Deleted).Select(x => x.Entity).FirstOrDefault(predicate.Compile()); + + //find in change tracker + if (entity != null && Entry(entity).State == EntityState.Deleted) { - this.ChangeTracker.DetectChanges(); + Entry(entity).State = EntityState.Modified; } - } \ No newline at end of file + + //find in currentDb + if (entity == null) + { + entity = Set().FirstOrDefault(predicate); + } + + //find in up-to-date db + if (entity == null) + { + var newAppDB = CreateCopy(); + + entity = newAppDB.Set().FirstOrDefault(predicate); + if (entity != null) + Attach(entity); + } + + //create if not found + if (entity == null) + { + var newEntity = factory(); + this.Add(newEntity); + entity = newEntity; + } + + return entity; + } + + public T Create(Action config = null, params object[] constructorArguments) + { + var entity = this.CreateProxy(constructorArguments); + + config?.Invoke(entity); + this.Add(entity); + this.ChangeTracker.DetectChanges(); + return entity; + } + public object Create(Type entityType, Action config = null, params object[] constructorArguments) + { + var entity = this.CreateProxy(entityType, constructorArguments); + + config?.Invoke(entity); + this.Add(entity); + this.ChangeTracker.DetectChanges(); + return entity; + } + + public void ApplyRelationships() + { + this.ChangeTracker.DetectChanges(); + } +} \ No newline at end of file diff --git a/Utils/Extensions.cs b/Utils/Extensions.cs index c42c2de..b24b7aa 100644 --- a/Utils/Extensions.cs +++ b/Utils/Extensions.cs @@ -33,6 +33,20 @@ public static class Extensions Data = query.Select(projector).AsAsyncEnumerable() }; } + public static async Task> ApplyPaginationRes(this IQueryable query, IServiceProvider providers, Pagination pag, Expression> projector) where TResponseType : IAutomappedAttribute, new() + { + var totalCount = await query.CountAsync(); + query = query.Skip(pag.Offset); + query = query.Take(pag.Count); + + return new PaginationResponse + { + TotalCount = totalCount, + Offset = pag.Offset, + Count = pag.Count, + Data = query.Select(projector).AsAsyncEnumerable() + }; + } public static async Task> ApplySearchPaginationRes(this IQueryable query, string? search, IServiceProvider providers, Pagination pag, List>> matchers) where TType : class From a203c2b6d1c36d113c1412041b7d279d404b2276 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sat, 11 Jan 2025 19:36:51 +0100 Subject: [PATCH 4/6] missed IApiDescriptionVisibility for oneOf, allOf, anyOf --- .../IApiDescriptionVisibilitySchemaFilter.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Controllers/IApiDescriptionVisibilitySchemaFilter.cs b/Controllers/IApiDescriptionVisibilitySchemaFilter.cs index be05efe..8d95262 100644 --- a/Controllers/IApiDescriptionVisibilitySchemaFilter.cs +++ b/Controllers/IApiDescriptionVisibilitySchemaFilter.cs @@ -39,7 +39,7 @@ namespace NejCommon.Controllers var key = context.SchemaRepository.Schemas.FirstOrDefault(k => k.Value == schema).Key; if (string.IsNullOrWhiteSpace(key)) continue; - //Console.WriteLine($"Removing schema {key}"); + Console.WriteLine($"Removing schema {key}"); context.SchemaRepository.Schemas.Remove(key); } } @@ -86,7 +86,7 @@ namespace NejCommon.Controllers var orphanedSchemas = swaggerDoc.Components.Schemas.Where(x => !currentSchemas.ContainsKey(x.Key)).ToDictionary(x => x.Key, x => x.Value); - //Console.WriteLine($"Unused schemas: {string.Join(", ", orphanedSchemas.Keys)}"); + Console.WriteLine($"Unused schemas: {string.Join(", ", orphanedSchemas.Keys)}"); swaggerDoc.Components.Schemas = swaggerDoc.Components.Schemas.Where(x => currentSchemas.ContainsKey(x.Key)).ToDictionary(x => x.Key, x => x.Value); } @@ -113,6 +113,18 @@ namespace NejCommon.Controllers { ExpandSchemas(repo, currentSchemas, sch); } + foreach(var sch in schemaToExpand.OneOf) + { + ExpandSchemas(repo, currentSchemas, sch); + } + foreach(var sch in schemaToExpand.AnyOf) + { + ExpandSchemas(repo, currentSchemas, sch); + } + foreach(var sch in schemaToExpand.AllOf) + { + ExpandSchemas(repo, currentSchemas, sch); + } } } } \ No newline at end of file From 4c7a280230071904f9fd600f30647b55d96fe0f3 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sun, 12 Jan 2025 20:12:18 +0100 Subject: [PATCH 5/6] add back FileStreamability --- Controllers/TypedResultsPolyfill.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Controllers/TypedResultsPolyfill.cs b/Controllers/TypedResultsPolyfill.cs index 5bb51a9..dcce209 100644 --- a/Controllers/TypedResultsPolyfill.cs +++ b/Controllers/TypedResultsPolyfill.cs @@ -86,6 +86,18 @@ namespace NejCommon.Controllers operation.Responses.Add("401", new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(401) }); } + + var generics = actionReturnType.GetGenericArguments(); + foreach (var generic in generics) + { + if (generic == typeof(FileStreamHttpResult)) + { + var statusCode = "200"; + var oar = new OpenApiResponse { Description = GetResponseDescription(statusCode) }; + oar.Content.Add( "application/octet-stream", new OpenApiMediaType { Schema = new OpenApiSchema { Type = "string", Format = "binary" } }); + operation.Responses.Add(statusCode, oar); + } + } } private static bool IsControllerAction(OperationFilterContext context) From 4120335a6aece2ab7dc8cdbe60a8343cbf6c8eaa Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Mon, 13 Jan 2025 18:18:45 +0100 Subject: [PATCH 6/6] move projectors a bit around --- Utils/Extensions.cs | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/Utils/Extensions.cs b/Utils/Extensions.cs index b24b7aa..6ecf2ab 100644 --- a/Utils/Extensions.cs +++ b/Utils/Extensions.cs @@ -17,27 +17,14 @@ public static class Extensions query = query.Take(pag.Count); return query; } - - public static async Task> ApplyPaginationRes(this IQueryable query, IServiceProvider providers, Pagination pag) where TResponseType : IAutomappedAttribute, new() + public static async Task> ApplyPaginationRes(this IQueryable query, IServiceProvider providers, Pagination pag, Expression>? projector = null) where TResponseType : IAutomappedAttribute, new() { var totalCount = await query.CountAsync(); query = query.Skip(pag.Offset); query = query.Take(pag.Count); - var projector = new TResponseType().GetProjectorFrom(providers); - return new PaginationResponse - { - TotalCount = totalCount, - Offset = pag.Offset, - Count = pag.Count, - Data = query.Select(projector).AsAsyncEnumerable() - }; - } - public static async Task> ApplyPaginationRes(this IQueryable query, IServiceProvider providers, Pagination pag, Expression> projector) where TResponseType : IAutomappedAttribute, new() - { - var totalCount = await query.CountAsync(); - query = query.Skip(pag.Offset); - query = query.Take(pag.Count); + if (projector == null) + projector = new TResponseType().GetProjectorFrom(providers); return new PaginationResponse { @@ -48,7 +35,7 @@ public static class Extensions }; } - public static async Task> ApplySearchPaginationRes(this IQueryable query, string? search, IServiceProvider providers, Pagination pag, List>> matchers) + public static async Task> ApplySearchPaginationRes(this IQueryable query, string? search, IServiceProvider providers, Pagination pag, List>> matchers, Expression>? projector = null) where TType : class where TResponseType : IAutomappedAttribute, new() where TEntityBinder : EntityBinder @@ -103,7 +90,7 @@ public static class Extensions } } - return await query.ApplyPaginationRes(providers, pag); + return await query.ApplyPaginationRes(providers, pag, projector); } [Projectable]