using System.Globalization;
using System.Text;
namespace Sunfish.Http;
/// <summary>
/// Respuesta HTTP. Las cabeceras se envian al empezar el cuerpo. Con longitud conocida se usa
/// Content-Length (los clientes antiguos no conocen chunked y asi muestran el progreso); sin ella,
/// chunked para HTTP/1.1 y cierre de conexion para 1.0. HTTP/0.9 recibe solo el cuerpo.
/// </summary>
public sealed class HttpResponse
{
private readonly Stream _stream;
private readonly HttpRequest _request;
private Stream? _body;
internal HttpResponse(Stream stream, HttpRequest request)
{
_stream = stream;
_request = request;
KeepAlive = request.WantsKeepAlive;
}
public int StatusCode { get; set; } = 200;
public HttpHeaders Headers { get; } = new();
public bool HeadersSent => _body is not null;
/// <summary>Si la conexion seguira abierta despues de esta respuesta.</summary>
public bool KeepAlive { get; private set; }
/// <summary>Bytes de cuerpo enviados (para el registro).</summary>
public long BytesSent => _body is CountingStream c ? c.Count : 0;
/// <summary>Envia la cabecera y devuelve el flujo para escribir el cuerpo.</summary>
public async Task<Stream> StartAsync(long? contentLength, CancellationToken ct = default)
{
if (_body is not null)
throw new InvalidOperationException("Response already started");
bool bodyAllowed = StatusCode is not (204 or 304) && StatusCode >= 200;
bool chunked = false;
if (bodyAllowed && contentLength is long length)
{
Headers["Content-Length"] = length.ToString(CultureInfo.InvariantCulture);
}
else if (bodyAllowed)
{
if (_request.IsHttp11OrLater)
{
Headers["Transfer-Encoding"] = "chunked";
chunked = true;
}
else
{
KeepAlive = false; // 1.0 sin longitud: el final del cuerpo es el cierre
}
}
if (!_request.IsHttp09)
{
if (KeepAlive)
{
if (!_request.IsHttp11OrLater)
Headers["Connection"] = "keep-alive";
}
else
{
Headers["Connection"] = "close";
}
Headers["Date"] ??= FormatDate(DateTime.UtcNow);
Headers["Server"] ??= "Sunfish/3.0";
await _stream.WriteAsync(BuildHead(), ct);
}
Stream body = _stream;
if (_request.IsHead || !bodyAllowed)
body = Stream.Null;
else if (chunked)
body = new ChunkedWriteStream(_stream);
_body = new CountingStream(body);
return _body;
}
/// <summary>Respuesta completa con el cuerpo en memoria.</summary>
public async Task SendAsync(byte[] content, string? contentType, CancellationToken ct = default)
{
if (contentType is not null)
Headers["Content-Type"] = contentType;
var body = await StartAsync(content.Length, ct);
await body.WriteAsync(content, ct);
}
/// <summary>Termina la respuesta: si no se envio nada, una vacia; si era chunked, el trozo final.</summary>
internal async Task CompleteAsync(CancellationToken ct)
{
if (_body is null)
await StartAsync(0, ct);
if (_body is CountingStream { Inner: ChunkedWriteStream chunked })
await chunked.FinishAsync(ct);
await _stream.FlushAsync(ct);
}
private byte[] BuildHead()
{
var sb = new StringBuilder();
string version = _request.IsHttp11OrLater ? "HTTP/1.1" : "HTTP/1.0";
sb.Append(version).Append(' ').Append(StatusCode.ToString(CultureInfo.InvariantCulture))
.Append(' ').Append(ReasonPhrase(StatusCode)).Append("\r\n");
foreach (var (name, value) in Headers.All)
sb.Append(name).Append(": ").Append(value).Append("\r\n");
sb.Append("\r\n");
return Encoding.Latin1.GetBytes(sb.ToString());
}
public static string FormatDate(DateTime utc) =>
utc.ToString("r", CultureInfo.InvariantCulture);
public static string ReasonPhrase(int status) => status switch
{
100 => "Continue",
200 => "OK",
201 => "Created",
204 => "No Content",
206 => "Partial Content",
207 => "Multi-Status",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
412 => "Precondition Failed",
413 => "Payload Too Large",
414 => "URI Too Long",
416 => "Range Not Satisfiable",
431 => "Request Header Fields Too Large",
500 => "Internal Server Error",
501 => "Not Implemented",
503 => "Service Unavailable",
505 => "HTTP Version Not Supported",
_ => "Unknown",
};
}
/// <summary>Cuenta los bytes escritos.</summary>
internal sealed class CountingStream(Stream inner) : Stream
{
public Stream Inner { get; } = inner;
public long Count { get; private set; }
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
await Inner.WriteAsync(buffer, ct);
Count += buffer.Length;
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
WriteAsync(buffer.AsMemory(offset, count), ct).AsTask();
public override void Write(byte[] buffer, int offset, int count) =>
WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
public override Task FlushAsync(CancellationToken ct) => Inner.FlushAsync(ct);
public override void Flush() => Inner.Flush();
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
/// <summary>Escritura con Transfer-Encoding: chunked.</summary>
internal sealed class ChunkedWriteStream(Stream inner) : Stream
{
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
if (buffer.Length == 0)
return; // un trozo vacio marcaria el final
await inner.WriteAsync(Encoding.ASCII.GetBytes(buffer.Length.ToString("X", CultureInfo.InvariantCulture) + "\r\n"), ct);
await inner.WriteAsync(buffer, ct);
await inner.WriteAsync("\r\n"u8.ToArray(), ct);
}
public Task FinishAsync(CancellationToken ct) => inner.WriteAsync("0\r\n\r\n"u8.ToArray(), ct).AsTask();
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
WriteAsync(buffer.AsMemory(offset, count), ct).AsTask();
public override void Write(byte[] buffer, int offset, int count) =>
WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct);
public override void Flush() => inner.Flush();
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}