🚚 Rename the DysonNetwork.Shared.Http module
This commit is contained in:
135
DysonNetwork.Shared/Networking/ApiError.cs
Normal file
135
DysonNetwork.Shared/Networking/ApiError.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Shared.Networking;
|
||||
|
||||
/// <summary>
|
||||
/// Standardized error payload to return to clients.
|
||||
/// Inspired by RFC7807 (problem+json) with app-specific fields.
|
||||
/// </summary>
|
||||
public class ApiError
|
||||
{
|
||||
/// <summary>
|
||||
/// Application-specific error code (e.g., "VALIDATION_ERROR", "NOT_FOUND", "SERVER_ERROR").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = "UNKNOWN_ERROR";
|
||||
|
||||
/// <summary>
|
||||
/// Short, human-readable message for the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = "An unexpected error occurred.";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code to be used by the server when sending this error.
|
||||
/// Optional to keep the model transport-agnostic.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// More detailed description of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Server trace identifier (e.g., from HttpContext.TraceIdentifier) to help debugging.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Field-level validation errors: key is the field name, value is an array of messages.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errors")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string[]>? Errors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary additional metadata for clients.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meta")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, object?>? Meta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a validation error payload.
|
||||
/// </summary>
|
||||
public static ApiError Validation(
|
||||
Dictionary<string, string[]> errors,
|
||||
string? message = null,
|
||||
int status = 400,
|
||||
string code = "VALIDATION_ERROR",
|
||||
string? traceId = null)
|
||||
{
|
||||
return new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = message ?? "One or more validation errors occurred.",
|
||||
Status = status,
|
||||
Errors = errors,
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a not-found error payload.
|
||||
/// </summary>
|
||||
public static ApiError NotFound(
|
||||
string resource,
|
||||
string? message = null,
|
||||
int status = 404,
|
||||
string code = "NOT_FOUND",
|
||||
string? traceId = null)
|
||||
{
|
||||
return new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = message ?? $"The requested resource '{resource}' was not found.",
|
||||
Status = status,
|
||||
Detail = resource,
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a generic server error payload.
|
||||
/// </summary>
|
||||
public static ApiError Server(
|
||||
string? message = null,
|
||||
int status = 500,
|
||||
string code = "SERVER_ERROR",
|
||||
string? traceId = null,
|
||||
string? detail = null)
|
||||
{
|
||||
return new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = message ?? "An internal server error occurred.",
|
||||
Status = status,
|
||||
TraceId = traceId,
|
||||
Detail = detail
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for an unauthorized/forbidden error payload.
|
||||
/// </summary>
|
||||
public static ApiError Unauthorized(
|
||||
string? message = null,
|
||||
bool forbidden = false,
|
||||
string? traceId = null)
|
||||
{
|
||||
return new ApiError
|
||||
{
|
||||
Code = forbidden ? "FORBIDDEN" : "UNAUTHORIZED",
|
||||
Message = message ?? (forbidden ? "You do not have permission to perform this action." : "Authentication is required."),
|
||||
Status = forbidden ? 403 : 401,
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
}
|
||||
76
DysonNetwork.Shared/Networking/KestrelConfiguration.cs
Normal file
76
DysonNetwork.Shared/Networking/KestrelConfiguration.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace DysonNetwork.Shared.Networking;
|
||||
|
||||
public static class KestrelConfiguration
|
||||
{
|
||||
public static WebApplicationBuilder ConfigureAppKestrel(
|
||||
this WebApplicationBuilder builder,
|
||||
IConfiguration configuration,
|
||||
long maxRequestBodySize = 50 * 1024 * 1024,
|
||||
bool enableGrpc = true
|
||||
)
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = maxRequestBodySize;
|
||||
|
||||
if (enableGrpc)
|
||||
{
|
||||
// gRPC
|
||||
var grpcPort = int.Parse(configuration.GetValue("GRPC_PORT", "5001"));
|
||||
options.ListenAnyIP(grpcPort, listenOptions =>
|
||||
{
|
||||
listenOptions.Protocols = HttpProtocols.Http2;
|
||||
|
||||
var selfSignedCert = _CreateSelfSignedCertificate();
|
||||
listenOptions.UseHttps(selfSignedCert);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
var httpPorts = configuration.GetValue("HTTP_PORTS", "6000")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(p => int.Parse(p.Trim()))
|
||||
.ToArray();
|
||||
|
||||
// Regular HTTP
|
||||
foreach (var httpPort in httpPorts)
|
||||
options.ListenAnyIP(httpPort,
|
||||
listenOptions => { listenOptions.Protocols = HttpProtocols.Http1AndHttp2; });
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
static X509Certificate2 _CreateSelfSignedCertificate()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var certRequest = new CertificateRequest(
|
||||
"CN=dyson.network", // Common Name for the certificate
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
// Add extensions (e.g., for server authentication)
|
||||
certRequest.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, // Server Authentication
|
||||
false));
|
||||
|
||||
// Set validity period (e.g., 1 year)
|
||||
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var notAfter = notBefore.AddYears(1);
|
||||
|
||||
var certificate = certRequest.CreateSelfSigned(notBefore, notAfter);
|
||||
|
||||
// Export to PKCS#12 and load using X509CertificateLoader
|
||||
var pfxBytes = certificate.Export(X509ContentType.Pfx);
|
||||
return X509CertificateLoader.LoadPkcs12(pfxBytes, password: null);
|
||||
}
|
||||
}
|
||||
41
DysonNetwork.Shared/Networking/KnownProxiesConfiguration.cs
Normal file
41
DysonNetwork.Shared/Networking/KnownProxiesConfiguration.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using IPNetwork = System.Net.IPNetwork;
|
||||
|
||||
namespace DysonNetwork.Shared.Networking;
|
||||
|
||||
public static class KnownProxiesConfiguration
|
||||
{
|
||||
public static WebApplication ConfigureForwardedHeaders(this WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
var knownProxiesSection = configuration.GetSection("KnownProxies");
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
|
||||
if (knownProxiesSection.Exists())
|
||||
{
|
||||
var proxyAddresses = knownProxiesSection.Get<string[]>();
|
||||
if (proxyAddresses != null)
|
||||
{
|
||||
foreach (var proxy in proxyAddresses)
|
||||
{
|
||||
if (IPAddress.TryParse(proxy, out var ipAddress))
|
||||
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
|
||||
else if (IPNetwork.TryParse(proxy, out var ipNetwork))
|
||||
forwardedHeadersOptions.KnownIPNetworks.Add(ipNetwork);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (forwardedHeadersOptions.KnownProxies.Count == 0 && forwardedHeadersOptions.KnownIPNetworks.Count == 0)
|
||||
{
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
97
DysonNetwork.Shared/Networking/SwaggerGen.cs
Normal file
97
DysonNetwork.Shared/Networking/SwaggerGen.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace DysonNetwork.Shared.Networking;
|
||||
|
||||
public static class SwaggerGen
|
||||
{
|
||||
public static WebApplicationBuilder AddSwaggerManifest(
|
||||
this WebApplicationBuilder builder,
|
||||
string serviceName,
|
||||
string? serviceDescription
|
||||
)
|
||||
{
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Version = "v1",
|
||||
Title = serviceName,
|
||||
Description = serviceDescription,
|
||||
TermsOfService = new Uri("https://solsynth.dev/terms"),
|
||||
License = new OpenApiLicense
|
||||
{
|
||||
Name = "APGLv3",
|
||||
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
|
||||
}
|
||||
});
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Solar Network Unified Authentication",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
BearerFormat = "JWT",
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
});
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication UseSwaggerManifest(this WebApplication app, string serviceName)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
|
||||
var configuration = app.Configuration;
|
||||
app.UseSwagger(c =>
|
||||
{
|
||||
c.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
|
||||
{
|
||||
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
|
||||
|
||||
// Rewrite all path keys (remove /api or replace it)
|
||||
var newPaths = new OpenApiPaths();
|
||||
foreach (var (path, pathItem) in swaggerDoc.Paths)
|
||||
{
|
||||
// e.g. original path = "/api/drive/chunk/{taskId}/{chunkIndex}"
|
||||
// We want to produce "/sphere/drive/chunk/{taskId}/{chunkIndex}" or maybe "/sphere/chunk/..."
|
||||
var newPathKey = path;
|
||||
|
||||
// If "path" starts with "/api", strip it
|
||||
if (newPathKey.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
newPathKey = newPathKey["/api".Length..];
|
||||
if (!newPathKey.StartsWith("/"))
|
||||
newPathKey = "/" + newPathKey;
|
||||
}
|
||||
|
||||
// Then prepend the public base path (if not root)
|
||||
if (!string.IsNullOrEmpty(publicBasePath) && publicBasePath != "/")
|
||||
{
|
||||
// ensure slash composition
|
||||
newPathKey = publicBasePath.TrimEnd('/') + newPathKey;
|
||||
}
|
||||
|
||||
newPaths.Add(newPathKey, pathItem);
|
||||
}
|
||||
|
||||
swaggerDoc.Paths = newPaths;
|
||||
});
|
||||
});
|
||||
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
// Swagger UI must point to the JSON location
|
||||
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
|
||||
options.SwaggerEndpoint(
|
||||
$"{publicBasePath}/swagger/v1/swagger.json",
|
||||
$"{serviceName} API v1");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user