🚚 Rename the DysonNetwork.Shared.Http module

This commit is contained in:
2026-01-18 20:26:34 +08:00
parent a3c1d74501
commit fc2215ec63
24 changed files with 24 additions and 24 deletions

View 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
};
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}