Created
June 20, 2025 05:00
-
-
Save yogesh-singh-maersk/a8da0f23130936593382d2d9df6f3236 to your computer and use it in GitHub Desktop.
Bulk Equipment Controller - minimal APIs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Project Sdk="Microsoft.NET.Sdk.Web"> | |
<PropertyGroup> | |
<TargetFramework>net9.0</TargetFramework> | |
<Nullable>enable</Nullable> | |
<ImplicitUsings>enable</ImplicitUsings> | |
</PropertyGroup> | |
<ItemGroup> | |
<PackageReference Include="Scalar.AspNetCore" Version="2.4.20" /> | |
<PackageReference Include="FluentValidation" Version="11.9.0" /> | |
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> | |
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> | |
</ItemGroup> | |
</Project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Text.Json; | |
using System.Text.Json.Serialization; | |
using FluentValidation; | |
using Scalar.AspNetCore; | |
using System.Collections.Concurrent; | |
var builder = WebApplication.CreateBuilder(args); | |
// Register packages | |
builder.Services.AddEndpointsApiExplorer(); | |
builder.Services.AddSwaggerGen(); | |
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); | |
//builder.Services.AddOpenApi(); // Optional, from Scalar.AspNetCore | |
builder.Services.AddSingleton(TimeProvider.System); | |
// Custom services | |
builder.Services.AddSingleton<IBulkUpdateJobRepository, InMemoryBulkUpdateJobRepository>(); | |
builder.Services.AddSingleton<IMessagePublisher, MockMessagePublisher>(); | |
// JSON options | |
builder.Services.ConfigureHttpJsonOptions(options => | |
{ | |
options.SerializerOptions.Converters.Add(new OptionalJsonConverterFactory()); | |
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); | |
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; | |
}); | |
var app = builder.Build(); | |
app.UseSwagger(); | |
app.UseSwaggerUI(); | |
app.MapGet("/", () => "Hello, world!"); | |
//app.MapOpenApi(); | |
app.MapScalarApiReference(); | |
app.MapPut("/equipment/bulk-update", async ( | |
BulkEquipmentRequest request, | |
IValidator<BulkEquipmentRequest> validator, | |
IBulkUpdateJobRepository jobStore, | |
IMessagePublisher messagePublisher, | |
TimeProvider timeProvider) => | |
{ | |
var validationResult = await validator.ValidateAsync(request); | |
if (!validationResult.IsValid) | |
return Results.BadRequest(validationResult.Errors.Select(e => e.ErrorMessage)); | |
var job = new BulkUpdateJob | |
{ | |
Id = Guid.NewGuid(), | |
Request = request, | |
Status = BulkUpdateJobStatus.Created, | |
CreatedAt = timeProvider.GetUtcNow(), | |
TotalEquipmentCount = request.EquipmentNumbers.Count | |
}; | |
await jobStore.Save(job); | |
var message = new BulkUpdateJobCreated | |
{ | |
JobId = job.Id, | |
CreatedAt = job.CreatedAt, | |
EquipmentCount = job.TotalEquipmentCount | |
}; | |
await messagePublisher.PublishAsync(message); | |
var response = new BulkUpdateJobResponse | |
{ | |
JobId = job.Id, | |
Status = job.Status.ToString(), | |
CreatedAt = job.CreatedAt, | |
TotalEquipmentCount = job.TotalEquipmentCount, | |
Message = "Bulk update job has been created and will be processed in the background" | |
}; | |
return Results.Accepted($"/equipment/bulk-update/{job.Id}/status", response); | |
}); | |
app.MapGet("/equipment/bulk-update/{id:guid}/status", async ( | |
Guid id, | |
IBulkUpdateJobRepository jobStore) => | |
{ | |
var job = await jobStore.GetJob(id); | |
if (job == null) | |
return Results.NotFound($"Bulk update job with ID {id} not found"); | |
var response = new BulkUpdateJobStatusResponse | |
{ | |
JobId = job.Id, | |
Status = job.Status.ToString(), | |
CreatedAt = job.CreatedAt, | |
StartedAt = job.StartedAt, | |
CompletedAt = job.CompletedAt, | |
TotalEquipmentCount = job.TotalEquipmentCount, | |
ProcessedCount = job.ProcessedCount, | |
SuccessCount = job.SuccessCount, | |
FailureCount = job.FailureCount, | |
ErrorMessage = job.ErrorMessage, | |
Results = job.Results | |
}; | |
return Results.Ok(response); | |
}); | |
app.Run(); | |
// ----- Models & DTOs ----- | |
public sealed record BulkEquipmentRequest | |
{ | |
public required string Action { get; init; } | |
public EquipmentType EquipmentType { get; init; } | |
public List<string> EquipmentNumbers { get; init; } = []; | |
public required ContainerUpdateBase ContainerProperties { get; init; } | |
public string? OutfleetReason { get; init; } | |
} | |
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$equipmentType")] | |
[JsonDerivedType(typeof(DryContainerUpdate), "dry")] | |
[JsonDerivedType(typeof(ReeferContainerUpdate), "reefer")] | |
public abstract record ContainerUpdateBase; | |
public sealed record DryContainerUpdate : ContainerUpdateBase | |
{ | |
public Optional<decimal?>? FloorWidthInMm { get; init; } | |
public Optional<int?>? LashingTopRailCount { get; init; } | |
} | |
public sealed record ReeferContainerUpdate : ContainerUpdateBase | |
{ | |
public Optional<decimal?>? AirFlowHighSpeedCubicMeterPerHour { get; init; } | |
public Optional<decimal?>? AirFlowLowSpeedCubicMeterPerHour { get; init; } | |
} | |
public sealed record BulkUpdateJob | |
{ | |
public required Guid Id { get; init; } | |
public required BulkEquipmentRequest Request { get; init; } | |
public required BulkUpdateJobStatus Status { get; set; } | |
public required DateTimeOffset CreatedAt { get; init; } | |
public DateTimeOffset? StartedAt { get; set; } | |
public DateTimeOffset? CompletedAt { get; set; } | |
public required int TotalEquipmentCount { get; init; } | |
public int ProcessedCount { get; set; } | |
public int SuccessCount { get; set; } | |
public int FailureCount { get; set; } | |
public string? ErrorMessage { get; set; } | |
public List<EquipmentUpdateResult> Results { get; set; } = []; | |
} | |
public enum BulkUpdateJobStatus | |
{ | |
Created, Processing, Completed, Failed | |
} | |
public sealed record BulkUpdateJobResponse | |
{ | |
public required Guid JobId { get; init; } | |
public required string Status { get; init; } | |
public required DateTimeOffset CreatedAt { get; init; } | |
public required int TotalEquipmentCount { get; init; } | |
public required string Message { get; init; } | |
} | |
public sealed record BulkUpdateJobStatusResponse | |
{ | |
public required Guid JobId { get; init; } | |
public required string Status { get; init; } | |
public required DateTimeOffset CreatedAt { get; init; } | |
public DateTimeOffset? StartedAt { get; init; } | |
public DateTimeOffset? CompletedAt { get; init; } | |
public required int TotalEquipmentCount { get; init; } | |
public int ProcessedCount { get; init; } | |
public int SuccessCount { get; init; } | |
public int FailureCount { get; init; } | |
public string? ErrorMessage { get; init; } | |
public List<EquipmentUpdateResult> Results { get; init; } = []; | |
} | |
public sealed record BulkUpdateJobCreated | |
{ | |
public required Guid JobId { get; init; } | |
public required DateTimeOffset CreatedAt { get; init; } | |
public required int EquipmentCount { get; init; } | |
} | |
public interface IBulkUpdateJobRepository | |
{ | |
Task Save(BulkUpdateJob job); | |
Task<BulkUpdateJob?> GetJob(Guid jobId); | |
Task UpdateJob(BulkUpdateJob job); | |
} | |
public interface IMessagePublisher | |
{ | |
Task PublishAsync<T>(T message) where T : class; | |
} | |
public class InMemoryBulkUpdateJobRepository : IBulkUpdateJobRepository | |
{ | |
private readonly ConcurrentDictionary<Guid, BulkUpdateJob> _jobs = new(); | |
public Task Save(BulkUpdateJob job) { _jobs.TryAdd(job.Id, job); return Task.CompletedTask; } | |
public Task<BulkUpdateJob?> GetJob(Guid jobId) => Task.FromResult(_jobs.GetValueOrDefault(jobId)); | |
public Task UpdateJob(BulkUpdateJob job) { _jobs[job.Id] = job; return Task.CompletedTask; } | |
} | |
public class MockMessagePublisher : IMessagePublisher | |
{ | |
public Task PublishAsync<T>(T message) where T : class | |
{ | |
Console.WriteLine($"Publishing message: {typeof(T).Name}"); | |
Console.WriteLine(JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = true })); | |
return Task.CompletedTask; | |
} | |
} | |
public sealed record EquipmentUpdateResult | |
{ | |
public required string EquipmentNumber { get; init; } | |
public bool Success { get; init; } | |
public string? ErrorMessage { get; init; } | |
public List<string> UpdatedProperties { get; init; } = []; | |
} | |
public enum EquipmentType | |
{ | |
None, Dry, Reefer | |
} | |
public class Optional<T> | |
{ | |
public T Value { get; set; } = default!; | |
public bool HasValue { get; set; } | |
public static Optional<T> Some(T value) => new() { Value = value, HasValue = true }; | |
public static Optional<T> None() => new() { HasValue = false }; | |
public static implicit operator Optional<T>(T value) => Some(value); | |
} | |
public class OptionalJsonConverter<T> : JsonConverter<Optional<T>?> | |
{ | |
public override Optional<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | |
{ | |
if (reader.TokenType == JsonTokenType.Null) return null; | |
var value = JsonSerializer.Deserialize<T>(ref reader, options); | |
return new Optional<T> { Value = value!, HasValue = true }; | |
} | |
public override void Write(Utf8JsonWriter writer, Optional<T>? value, JsonSerializerOptions options) | |
{ | |
if (value?.HasValue is true) | |
JsonSerializer.Serialize(writer, value.Value, options); | |
else | |
writer.WriteNullValue(); | |
} | |
} | |
public class OptionalJsonConverterFactory : JsonConverterFactory | |
{ | |
public override bool CanConvert(Type typeToConvert) => | |
typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); | |
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) | |
{ | |
var valueType = typeToConvert.GetGenericArguments()[0]; | |
var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); | |
return (JsonConverter)Activator.CreateInstance(converterType)!; | |
} | |
} | |
public class BulkEquipmentRequestValidator : AbstractValidator<BulkEquipmentRequest> | |
{ | |
public BulkEquipmentRequestValidator() | |
{ | |
RuleFor(x => x.Action).NotEmpty().WithMessage("Action is required"); | |
RuleFor(x => x.EquipmentNumbers).NotEmpty().WithMessage("At least one equipment number is required"); | |
RuleFor(x => x.EquipmentNumbers).Must(e => e.Count <= 5000).WithMessage("Max 5000 allowed"); | |
RuleFor(x => x.ContainerProperties).NotNull().WithMessage("Container properties required"); | |
RuleFor(x => x.EquipmentType).NotEqual(EquipmentType.None).WithMessage("Equipment type required"); | |
RuleFor(x => x).Must(HaveMatchingContainerType).WithMessage("Container type mismatch"); | |
When(x => x.ContainerProperties is DryContainerUpdate dry, () => | |
{ | |
RuleFor(x => ((DryContainerUpdate)x.ContainerProperties).FloorWidthInMm!.Value) | |
.GreaterThan(0) | |
.When(x => ((DryContainerUpdate)x.ContainerProperties).FloorWidthInMm?.HasValue == true); | |
}); | |
When(x => x.ContainerProperties is ReeferContainerUpdate reefer, () => | |
{ | |
RuleFor(x => ((ReeferContainerUpdate)x.ContainerProperties).AirFlowHighSpeedCubicMeterPerHour!.Value) | |
.GreaterThan(0) | |
.When(x => ((ReeferContainerUpdate)x.ContainerProperties).AirFlowHighSpeedCubicMeterPerHour?.HasValue == true); | |
}); | |
} | |
private static bool HaveMatchingContainerType(BulkEquipmentRequest request) => | |
request.EquipmentType switch | |
{ | |
EquipmentType.Dry => request.ContainerProperties is DryContainerUpdate, | |
EquipmentType.Reefer => request.ContainerProperties is ReeferContainerUpdate, | |
_ => false | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment