Skip to content

Commit

Permalink
Create Imageflow.Server.ExampleModernAPI and CustomMediaEndpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
lilith committed Mar 21, 2024
1 parent 9fa7ab2 commit 944bc4a
Show file tree
Hide file tree
Showing 11 changed files with 597 additions and 1 deletion.
8 changes: 7 additions & 1 deletion Imageflow.Server.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29503.13
Expand Down Expand Up @@ -64,6 +64,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Routing", "src\Imaze
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Abstractions", "src\Imazen.Abstractions\Imazen.Abstractions.csproj", "{A04B9BE0-4931-4305-B9AB-B79737130F20}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.ExampleModernAPI", "examples\Imageflow.Server.ExampleModernAPI\Imageflow.Server.ExampleModernAPI.csproj", "{4AF9EFF8-5456-4711-B847-6DD31F949B02}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -158,6 +160,10 @@ Global
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.Build.0 = Release|Any CPU
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4AF9EFF8-5456-4711-B847-6DD31F949B02}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
204 changes: 204 additions & 0 deletions examples/Imageflow.Server.ExampleModernAPI/CustomMediaEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
using System.Buffers;
using System.Text;
using System.Text.Json;
using Imazen.Abstractions.Blobs;
using Imazen.Abstractions.Resulting;
using Imazen.Routing.HttpAbstractions;
using Imazen.Routing.Layers;
using Imazen.Routing.Promises;
using Imazen.Routing.Requests;

namespace Imageflow.Server.ExampleModernAPI;


internal record CustomFileData(string Path1, string QueryString1, string Path2, string QueryString2);

/// <summary>
/// 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.
/// </summary>
public class CustomMediaLayer(PathMapper jsonFileMapper) : Imazen.Routing.Layers.IRoutingLayer
{
public string Name => ".json.custom file handler";

public IFastCond? FastPreconditions => Conditions.HasPathSuffixOrdinalIgnoreCase(".json.custom");
public ValueTask<CodeResult<IRoutingEndpoint>?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default)
{
// FastPreconditions should have already been checked
var result = jsonFileMapper.TryMapVirtualPath(request.Path.Replace(".json.custom", ".json"));
if (result == null)
{
// no mapping found
return new ValueTask<CodeResult<IRoutingEndpoint>?>((CodeResult<IRoutingEndpoint>?)null);
}
var physicalPath = result.Value.MappedPhysicalPath;
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(physicalPath);
if (lastWriteTimeUtc.Year == 1601) // file doesn't exist, pass to next middleware
{
return new ValueTask<CodeResult<IRoutingEndpoint>?>((CodeResult<IRoutingEndpoint>?)null);
}
// Ok, the file exists. We can load and parse it using System.Text.Json to determine the dependencies.\
return RouteFromJsonFile(physicalPath, lastWriteTimeUtc, result.Value.MappingUsed, request, cancellationToken);
}

private async ValueTask<CodeResult<IRoutingEndpoint>?> RouteFromJsonFile(string jsonFilePath, DateTime lastWriteTimeUtc, IPathMapping mappingUsed, MutableRequest request, CancellationToken cancellationToken)
{
// TODO: here, we could cache the json files in memory using a key based on jsonFilePath and lastWriteTimeUtc.

var jsonText = await File.ReadAllTextAsync(jsonFilePath, cancellationToken);
var data = JsonSerializer.Deserialize<CustomFileData>(jsonText);
if (data == null)
{
return CodeResult<IRoutingEndpoint>.Err((HttpStatus.ServerError, "Failed to parse .json custom data file"));
}

return new PromiseWrappingEndpoint(new CustomMediaPromise(request.ToSnapshot(true),data));
}
}

internal class CustomMediaPromise(IRequestSnapshot r, CustomFileData data) : ICacheableBlobPromise
{
public bool IsCacheSupporting => true;
public IRequestSnapshot FinalRequest { get; } = r;

public async ValueTask<IAdaptableHttpResponse> CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline,
CancellationToken cancellationToken = default)
{
// This code path isn't called, it's just to satisfy the primitive IInstantPromise interface.
return new BlobResponse(await TryGetBlobAsync(request, router, pipeline, cancellationToken));
}

public bool HasDependencies => true;
public bool ReadyToWriteCacheKeyBasisData { get; private set; }

/// <summary>
/// Gets a promise for the given path that includes caching logic if indicated by the caching configuration and the latency by default.
/// </summary>
/// <param name="router"></param>
/// <param name="childRequestUri"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private async ValueTask<CodeResult<ICacheableBlobPromise>> RouteDependencyAsync(IBlobRequestRouter router, string childRequestUri,
CancellationToken cancellationToken = default)
{
if (FinalRequest.OriginatingRequest == null)
{
return CodeResult<ICacheableBlobPromise>.ErrFrom(HttpStatus.BadRequest, "OriginatingRequest is required, but was null");
}
var dependencyRequest = MutableRequest.ChildRequest(FinalRequest.OriginatingRequest, FinalRequest, childRequestUri, HttpMethods.Get);
var routingResult = await router.RouteToPromiseAsync(dependencyRequest, cancellationToken);
if (routingResult == null)
{
return CodeResult<ICacheableBlobPromise>.ErrFrom(HttpStatus.NotFound, "Dependency not found: " + childRequestUri);
}
if (routingResult.TryUnwrapError(out var error))
{
return CodeResult<ICacheableBlobPromise>.Err(error.WithAppend("Error routing to dependency: " + childRequestUri));
}
return CodeResult<ICacheableBlobPromise>.Ok(routingResult.Unwrap());
}
public async ValueTask<CodeResult> RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default)
{
var uri1 = data.Path1 + data.QueryString1;
var uri2 = data.Path2 + data.QueryString2;

foreach (var uri in new[]{uri1, uri2})
{
var routingResult = await RouteDependencyAsync(router, uri, cancellationToken);
if (routingResult.TryUnwrapError(out var error))
{
return CodeResult.Err(error);
}
Dependencies ??= new List<ICacheableBlobPromise>();
Dependencies.Add(routingResult.Unwrap());
}
ReadyToWriteCacheKeyBasisData = true;
return CodeResult.Ok();
}

internal List<ICacheableBlobPromise>? Dependencies { get; private set; }

private LatencyTrackingZone? latencyZone = null;
/// <summary>
/// Must route dependencies first!
/// </summary>
public LatencyTrackingZone? LatencyZone {
get
{
if (!ReadyToWriteCacheKeyBasisData) throw new InvalidOperationException("Dependencies must be routed first");
// produce a latency zone based on all dependency strings, joined, plus the sum of their latency defaults
if (latencyZone != null) return latencyZone;
var latency = 0;
var sb = new StringBuilder();
sb.Append("customMediaSwitcher(");
foreach (var dependency in Dependencies!)
{
latency += dependency.LatencyZone?.DefaultMs ?? 0;
sb.Append(dependency.LatencyZone?.TrackingZone ?? "(unknown)");
}
sb.Append(")");
latencyZone = new LatencyTrackingZone(sb.ToString(), latency, true); //AlwaysShield is true (never skip caching)
return latencyZone;
}
}

public void WriteCacheKeyBasisPairsToRecursive(IBufferWriter<byte> writer)
{
FinalRequest.WriteCacheKeyBasisPairsTo(writer);
if (Dependencies == null) throw new InvalidOperationException("Dependencies must be routed first");
foreach (var dependency in Dependencies)
{
dependency.WriteCacheKeyBasisPairsToRecursive(writer);
}

var otherCacheKeyData = 1;
writer.WriteInt(otherCacheKeyData);
}

private byte[]? cacheKey32Bytes = null;
public byte[] GetCacheKey32Bytes()
{
return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached();
}

public bool SupportsPreSignedUrls => false;

public async ValueTask<CodeResult<IBlobWrapper>> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline,
CancellationToken cancellationToken = default)
{
// Our logic is to return whichever dependency is smaller.
// This is a contrived example, but it's a good example of how to use dependencies.
var blobWrappers = new List<IBlobWrapper>();
var smallestBlob = default(IBlobWrapper);
try
{
foreach (var dependency in Dependencies!)
{
var result = await dependency.TryGetBlobAsync(request, router, pipeline, cancellationToken);
if (result.TryUnwrapError(out var error))
{
return CodeResult<IBlobWrapper>.Err(error);
}
var blob = result.Unwrap();
blobWrappers.Add(blob);

if (smallestBlob == null || blob.Attributes.EstimatedBlobByteCount < smallestBlob.Attributes.EstimatedBlobByteCount)
{
smallestBlob = blob;
}
}
if (smallestBlob == null)
{
return CodeResult<IBlobWrapper>.ErrFrom(HttpStatus.NotFound, "No dependencies found");
}
return CodeResult<IBlobWrapper>.Ok(smallestBlob.ForkReference());
}
finally
{
foreach (var blobWrapper in blobWrappers)
{
blobWrapper.Dispose();
}
}
}
}

23 changes: 23 additions & 0 deletions examples/Imageflow.Server.ExampleModernAPI/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["examples/Imageflow.Server.ExampleModernAPI/Imageflow.Server.ExampleModernAPI.csproj", "examples/Imageflow.Server.ExampleModernAPI/"]
RUN dotnet restore "examples/Imageflow.Server.ExampleModernAPI/Imageflow.Server.ExampleModernAPI.csproj"
COPY . .
WORKDIR "/src/examples/Imageflow.Server.ExampleModernAPI"
RUN dotnet build "Imageflow.Server.ExampleModernAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Imageflow.Server.ExampleModernAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Imageflow.Server.ExampleModernAPI.dll"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.3"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup>

<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Imageflow.Server\Imageflow.Server.csproj" />
<ProjectReference Include="..\..\src\Imazen.Abstractions\Imazen.Abstractions.csproj" />
<ProjectReference Include="..\..\src\Imazen.Common\Imazen.Common.csproj" />
<ProjectReference Include="..\..\src\Imazen.Routing\Imazen.Routing.csproj" />
</ItemGroup>

<ItemGroup>
<Folder Include="wwwroot\images\" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@Imageflow.Server.ExampleModernAPI_HostAddress = http://localhost:5025

GET {{Imageflow.Server.ExampleModernAPI_HostAddress}}/img/test.json.custom


###
70 changes: 70 additions & 0 deletions examples/Imageflow.Server.ExampleModernAPI/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Imageflow.Fluent;
using Imageflow.Server;
using Imageflow.Server.ExampleModernAPI;
using Imazen.Abstractions.Logging;
using Imazen.Routing.Layers;
using PathMapping = Imazen.Routing.Layers.PathMapping;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddImageflowLoggingSupport();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();


app.UseImageflow(new ImageflowMiddlewareOptions()
.MapPath("/images", Path.Join(builder.Environment.WebRootPath, "images"))
.SetMyOpenSourceProjectUrl("https://github.com/imazen/imageflow-dotnet-server")
.AddRoutingConfiguration((routing) =>
{
routing.ConfigureEndpoints((endpoints) =>
{
endpoints.AddLayer(new CustomMediaLayer(new PathMapper(new[]
{
new PathMapping("/img/", Path.Join(builder.Environment.ContentRootPath, "json"), true)
})));
});
}));


var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions examples/Imageflow.Server.ExampleModernAPI/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
6 changes: 6 additions & 0 deletions examples/Imageflow.Server.ExampleModernAPI/json/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"Path1": "/images/fire.jpg",
"Path2": "/images/fire.jpg",
"Querystring1": "format=webp&quality=80",
"Querystring2": "format=jpeg&quality=76"
}
Loading

0 comments on commit 944bc4a

Please sign in to comment.