Skip to content

Commit 944bc4a

Browse files
committed
Create Imageflow.Server.ExampleModernAPI and CustomMediaEndpoint
1 parent 9fa7ab2 commit 944bc4a

11 files changed

+597
-1
lines changed

Imageflow.Server.sln

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
# Visual Studio Version 16
44
VisualStudioVersion = 16.0.29503.13
@@ -64,6 +64,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Routing", "src\Imaze
6464
EndProject
6565
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Abstractions", "src\Imazen.Abstractions\Imazen.Abstractions.csproj", "{A04B9BE0-4931-4305-B9AB-B79737130F20}"
6666
EndProject
67+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.ExampleModernAPI", "examples\Imageflow.Server.ExampleModernAPI\Imageflow.Server.ExampleModernAPI.csproj", "{4AF9EFF8-5456-4711-B847-6DD31F949B02}"
68+
EndProject
6769
Global
6870
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6971
Debug|Any CPU = Debug|Any CPU
@@ -158,6 +160,10 @@ Global
158160
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Debug|Any CPU.Build.0 = Debug|Any CPU
159161
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.ActiveCfg = Release|Any CPU
160162
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.Build.0 = Release|Any CPU
163+
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
164+
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Debug|Any CPU.Build.0 = Debug|Any CPU
165+
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Release|Any CPU.ActiveCfg = Release|Any CPU
166+
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Release|Any CPU.Build.0 = Release|Any CPU
161167
EndGlobalSection
162168
GlobalSection(SolutionProperties) = preSolution
163169
HideSolutionNode = FALSE
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
using System.Buffers;
2+
using System.Text;
3+
using System.Text.Json;
4+
using Imazen.Abstractions.Blobs;
5+
using Imazen.Abstractions.Resulting;
6+
using Imazen.Routing.HttpAbstractions;
7+
using Imazen.Routing.Layers;
8+
using Imazen.Routing.Promises;
9+
using Imazen.Routing.Requests;
10+
11+
namespace Imageflow.Server.ExampleModernAPI;
12+
13+
14+
internal record CustomFileData(string Path1, string QueryString1, string Path2, string QueryString2);
15+
16+
/// <summary>
17+
/// This layer will capture requests for .json.custom paths. No .custom file actually exists, but the .json does, and we'll use that to determine the dependencies.
18+
/// </summary>
19+
public class CustomMediaLayer(PathMapper jsonFileMapper) : Imazen.Routing.Layers.IRoutingLayer
20+
{
21+
public string Name => ".json.custom file handler";
22+
23+
public IFastCond? FastPreconditions => Conditions.HasPathSuffixOrdinalIgnoreCase(".json.custom");
24+
public ValueTask<CodeResult<IRoutingEndpoint>?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default)
25+
{
26+
// FastPreconditions should have already been checked
27+
var result = jsonFileMapper.TryMapVirtualPath(request.Path.Replace(".json.custom", ".json"));
28+
if (result == null)
29+
{
30+
// no mapping found
31+
return new ValueTask<CodeResult<IRoutingEndpoint>?>((CodeResult<IRoutingEndpoint>?)null);
32+
}
33+
var physicalPath = result.Value.MappedPhysicalPath;
34+
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(physicalPath);
35+
if (lastWriteTimeUtc.Year == 1601) // file doesn't exist, pass to next middleware
36+
{
37+
return new ValueTask<CodeResult<IRoutingEndpoint>?>((CodeResult<IRoutingEndpoint>?)null);
38+
}
39+
// Ok, the file exists. We can load and parse it using System.Text.Json to determine the dependencies.\
40+
return RouteFromJsonFile(physicalPath, lastWriteTimeUtc, result.Value.MappingUsed, request, cancellationToken);
41+
}
42+
43+
private async ValueTask<CodeResult<IRoutingEndpoint>?> RouteFromJsonFile(string jsonFilePath, DateTime lastWriteTimeUtc, IPathMapping mappingUsed, MutableRequest request, CancellationToken cancellationToken)
44+
{
45+
// TODO: here, we could cache the json files in memory using a key based on jsonFilePath and lastWriteTimeUtc.
46+
47+
var jsonText = await File.ReadAllTextAsync(jsonFilePath, cancellationToken);
48+
var data = JsonSerializer.Deserialize<CustomFileData>(jsonText);
49+
if (data == null)
50+
{
51+
return CodeResult<IRoutingEndpoint>.Err((HttpStatus.ServerError, "Failed to parse .json custom data file"));
52+
}
53+
54+
return new PromiseWrappingEndpoint(new CustomMediaPromise(request.ToSnapshot(true),data));
55+
}
56+
}
57+
58+
internal class CustomMediaPromise(IRequestSnapshot r, CustomFileData data) : ICacheableBlobPromise
59+
{
60+
public bool IsCacheSupporting => true;
61+
public IRequestSnapshot FinalRequest { get; } = r;
62+
63+
public async ValueTask<IAdaptableHttpResponse> CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline,
64+
CancellationToken cancellationToken = default)
65+
{
66+
// This code path isn't called, it's just to satisfy the primitive IInstantPromise interface.
67+
return new BlobResponse(await TryGetBlobAsync(request, router, pipeline, cancellationToken));
68+
}
69+
70+
public bool HasDependencies => true;
71+
public bool ReadyToWriteCacheKeyBasisData { get; private set; }
72+
73+
/// <summary>
74+
/// Gets a promise for the given path that includes caching logic if indicated by the caching configuration and the latency by default.
75+
/// </summary>
76+
/// <param name="router"></param>
77+
/// <param name="childRequestUri"></param>
78+
/// <param name="cancellationToken"></param>
79+
/// <returns></returns>
80+
private async ValueTask<CodeResult<ICacheableBlobPromise>> RouteDependencyAsync(IBlobRequestRouter router, string childRequestUri,
81+
CancellationToken cancellationToken = default)
82+
{
83+
if (FinalRequest.OriginatingRequest == null)
84+
{
85+
return CodeResult<ICacheableBlobPromise>.ErrFrom(HttpStatus.BadRequest, "OriginatingRequest is required, but was null");
86+
}
87+
var dependencyRequest = MutableRequest.ChildRequest(FinalRequest.OriginatingRequest, FinalRequest, childRequestUri, HttpMethods.Get);
88+
var routingResult = await router.RouteToPromiseAsync(dependencyRequest, cancellationToken);
89+
if (routingResult == null)
90+
{
91+
return CodeResult<ICacheableBlobPromise>.ErrFrom(HttpStatus.NotFound, "Dependency not found: " + childRequestUri);
92+
}
93+
if (routingResult.TryUnwrapError(out var error))
94+
{
95+
return CodeResult<ICacheableBlobPromise>.Err(error.WithAppend("Error routing to dependency: " + childRequestUri));
96+
}
97+
return CodeResult<ICacheableBlobPromise>.Ok(routingResult.Unwrap());
98+
}
99+
public async ValueTask<CodeResult> RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default)
100+
{
101+
var uri1 = data.Path1 + data.QueryString1;
102+
var uri2 = data.Path2 + data.QueryString2;
103+
104+
foreach (var uri in new[]{uri1, uri2})
105+
{
106+
var routingResult = await RouteDependencyAsync(router, uri, cancellationToken);
107+
if (routingResult.TryUnwrapError(out var error))
108+
{
109+
return CodeResult.Err(error);
110+
}
111+
Dependencies ??= new List<ICacheableBlobPromise>();
112+
Dependencies.Add(routingResult.Unwrap());
113+
}
114+
ReadyToWriteCacheKeyBasisData = true;
115+
return CodeResult.Ok();
116+
}
117+
118+
internal List<ICacheableBlobPromise>? Dependencies { get; private set; }
119+
120+
private LatencyTrackingZone? latencyZone = null;
121+
/// <summary>
122+
/// Must route dependencies first!
123+
/// </summary>
124+
public LatencyTrackingZone? LatencyZone {
125+
get
126+
{
127+
if (!ReadyToWriteCacheKeyBasisData) throw new InvalidOperationException("Dependencies must be routed first");
128+
// produce a latency zone based on all dependency strings, joined, plus the sum of their latency defaults
129+
if (latencyZone != null) return latencyZone;
130+
var latency = 0;
131+
var sb = new StringBuilder();
132+
sb.Append("customMediaSwitcher(");
133+
foreach (var dependency in Dependencies!)
134+
{
135+
latency += dependency.LatencyZone?.DefaultMs ?? 0;
136+
sb.Append(dependency.LatencyZone?.TrackingZone ?? "(unknown)");
137+
}
138+
sb.Append(")");
139+
latencyZone = new LatencyTrackingZone(sb.ToString(), latency, true); //AlwaysShield is true (never skip caching)
140+
return latencyZone;
141+
}
142+
}
143+
144+
public void WriteCacheKeyBasisPairsToRecursive(IBufferWriter<byte> writer)
145+
{
146+
FinalRequest.WriteCacheKeyBasisPairsTo(writer);
147+
if (Dependencies == null) throw new InvalidOperationException("Dependencies must be routed first");
148+
foreach (var dependency in Dependencies)
149+
{
150+
dependency.WriteCacheKeyBasisPairsToRecursive(writer);
151+
}
152+
153+
var otherCacheKeyData = 1;
154+
writer.WriteInt(otherCacheKeyData);
155+
}
156+
157+
private byte[]? cacheKey32Bytes = null;
158+
public byte[] GetCacheKey32Bytes()
159+
{
160+
return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached();
161+
}
162+
163+
public bool SupportsPreSignedUrls => false;
164+
165+
public async ValueTask<CodeResult<IBlobWrapper>> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline,
166+
CancellationToken cancellationToken = default)
167+
{
168+
// Our logic is to return whichever dependency is smaller.
169+
// This is a contrived example, but it's a good example of how to use dependencies.
170+
var blobWrappers = new List<IBlobWrapper>();
171+
var smallestBlob = default(IBlobWrapper);
172+
try
173+
{
174+
foreach (var dependency in Dependencies!)
175+
{
176+
var result = await dependency.TryGetBlobAsync(request, router, pipeline, cancellationToken);
177+
if (result.TryUnwrapError(out var error))
178+
{
179+
return CodeResult<IBlobWrapper>.Err(error);
180+
}
181+
var blob = result.Unwrap();
182+
blobWrappers.Add(blob);
183+
184+
if (smallestBlob == null || blob.Attributes.EstimatedBlobByteCount < smallestBlob.Attributes.EstimatedBlobByteCount)
185+
{
186+
smallestBlob = blob;
187+
}
188+
}
189+
if (smallestBlob == null)
190+
{
191+
return CodeResult<IBlobWrapper>.ErrFrom(HttpStatus.NotFound, "No dependencies found");
192+
}
193+
return CodeResult<IBlobWrapper>.Ok(smallestBlob.ForkReference());
194+
}
195+
finally
196+
{
197+
foreach (var blobWrapper in blobWrappers)
198+
{
199+
blobWrapper.Dispose();
200+
}
201+
}
202+
}
203+
}
204+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
2+
USER $APP_UID
3+
WORKDIR /app
4+
EXPOSE 8080
5+
EXPOSE 8081
6+
7+
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
8+
ARG BUILD_CONFIGURATION=Release
9+
WORKDIR /src
10+
COPY ["examples/Imageflow.Server.ExampleModernAPI/Imageflow.Server.ExampleModernAPI.csproj", "examples/Imageflow.Server.ExampleModernAPI/"]
11+
RUN dotnet restore "examples/Imageflow.Server.ExampleModernAPI/Imageflow.Server.ExampleModernAPI.csproj"
12+
COPY . .
13+
WORKDIR "/src/examples/Imageflow.Server.ExampleModernAPI"
14+
RUN dotnet build "Imageflow.Server.ExampleModernAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build
15+
16+
FROM build AS publish
17+
ARG BUILD_CONFIGURATION=Release
18+
RUN dotnet publish "Imageflow.Server.ExampleModernAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
19+
20+
FROM base AS final
21+
WORKDIR /app
22+
COPY --from=publish /app/publish .
23+
ENTRYPOINT ["dotnet", "Imageflow.Server.ExampleModernAPI.dll"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.3"/>
12+
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<Content Include="..\..\.dockerignore">
17+
<Link>.dockerignore</Link>
18+
</Content>
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\..\src\Imageflow.Server\Imageflow.Server.csproj" />
23+
<ProjectReference Include="..\..\src\Imazen.Abstractions\Imazen.Abstractions.csproj" />
24+
<ProjectReference Include="..\..\src\Imazen.Common\Imazen.Common.csproj" />
25+
<ProjectReference Include="..\..\src\Imazen.Routing\Imazen.Routing.csproj" />
26+
</ItemGroup>
27+
28+
<ItemGroup>
29+
<Folder Include="wwwroot\images\" />
30+
</ItemGroup>
31+
32+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@Imageflow.Server.ExampleModernAPI_HostAddress = http://localhost:5025
2+
3+
GET {{Imageflow.Server.ExampleModernAPI_HostAddress}}/img/test.json.custom
4+
5+
6+
###
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Imageflow.Fluent;
2+
using Imageflow.Server;
3+
using Imageflow.Server.ExampleModernAPI;
4+
using Imazen.Abstractions.Logging;
5+
using Imazen.Routing.Layers;
6+
using PathMapping = Imazen.Routing.Layers.PathMapping;
7+
8+
var builder = WebApplication.CreateBuilder(args);
9+
10+
// Add services to the container.
11+
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
12+
builder.Services.AddEndpointsApiExplorer();
13+
builder.Services.AddSwaggerGen();
14+
15+
builder.Services.AddImageflowLoggingSupport();
16+
17+
var app = builder.Build();
18+
19+
// Configure the HTTP request pipeline.
20+
if (app.Environment.IsDevelopment())
21+
{
22+
app.UseSwagger();
23+
app.UseSwaggerUI();
24+
}
25+
26+
app.UseHttpsRedirection();
27+
28+
29+
app.UseImageflow(new ImageflowMiddlewareOptions()
30+
.MapPath("/images", Path.Join(builder.Environment.WebRootPath, "images"))
31+
.SetMyOpenSourceProjectUrl("https://github.com/imazen/imageflow-dotnet-server")
32+
.AddRoutingConfiguration((routing) =>
33+
{
34+
routing.ConfigureEndpoints((endpoints) =>
35+
{
36+
endpoints.AddLayer(new CustomMediaLayer(new PathMapper(new[]
37+
{
38+
new PathMapping("/img/", Path.Join(builder.Environment.ContentRootPath, "json"), true)
39+
})));
40+
});
41+
}));
42+
43+
44+
var summaries = new[]
45+
{
46+
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
47+
};
48+
49+
app.MapGet("/weatherforecast", () =>
50+
{
51+
var forecast = Enumerable.Range(1, 5).Select(index =>
52+
new WeatherForecast
53+
(
54+
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
55+
Random.Shared.Next(-20, 55),
56+
summaries[Random.Shared.Next(summaries.Length)]
57+
))
58+
.ToArray();
59+
return forecast;
60+
})
61+
.WithName("GetWeatherForecast")
62+
.WithOpenApi();
63+
64+
app.Run();
65+
66+
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
67+
{
68+
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
69+
}
70+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*"
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"Path1": "/images/fire.jpg",
3+
"Path2": "/images/fire.jpg",
4+
"Querystring1": "format=webp&quality=80",
5+
"Querystring2": "format=jpeg&quality=76"
6+
}

0 commit comments

Comments
 (0)