Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save yogesh-singh-maersk/a8da0f23130936593382d2d9df6f3236 to your computer and use it in GitHub Desktop.
Save yogesh-singh-maersk/a8da0f23130936593382d2d9df6f3236 to your computer and use it in GitHub Desktop.
Bulk Equipment Controller - minimal APIs
<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>
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