Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 126 additions & 18 deletions PeaceDatabase/Controllers/DbAndDocumentsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,45 @@
using PeaceDatabase.Core.Services;
using PeaceDatabase.Storage.Binary; // добавлено
using System.Diagnostics; // для бенчмарка
using Microsoft.AspNetCore.Http; // for IFormFile
using Microsoft.AspNetCore.Mvc.ApiExplorer; // for Tags attribute

namespace PeaceDatabase.WebApi.Controllers;

// Response models for better Swagger documentation
public class DatabaseResponse
{
public bool Ok { get; set; }
public string? Db { get; set; }
}

public class ErrorResponse
{
public bool Ok { get; set; }
public string? Error { get; set; }
public string? Db { get; set; }
public string? Id { get; set; }
public string? Rev { get; set; }
}

public class AllDocsResponse
{
public int Total { get; set; }
public IEnumerable<Document>? Items { get; set; }
}

public class SequenceResponse
{
public string? Db { get; set; }
public int Seq { get; set; }
}

// ===============================
// Управление БД
// ===============================
[ApiController]
[Route("v1/db")]
[Produces("application/json")]
public class DbApiController : ControllerBase
{
private readonly IDocumentService _svc;
Expand All @@ -33,42 +64,49 @@ public IActionResult HeadDb([FromRoute] string db)

// PUT /v1/db/{db}
[HttpPut("{db}")]
[ProducesResponseType(typeof(DatabaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public IActionResult CreateDb([FromRoute] string db)
{
var res = _svc.CreateDb(db);
if (!res.Ok) return Problem(res.Error ?? "db create failed", statusCode: 500);
return Ok(new { ok = true, db });
return Ok(new DatabaseResponse { Ok = true, Db = db });
}

// DELETE /v1/db/{db}
[HttpDelete("{db}")]
[ProducesResponseType(typeof(DatabaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public IActionResult DeleteDb([FromRoute] string db)
{
var res = _svc.DeleteDb(db);
if (!res.Ok)
{
var msg = res.Error ?? "db delete failed";
if (msg.Contains("not found", StringComparison.OrdinalIgnoreCase))
return NotFound(new { ok = false, error = msg, db });
return NotFound(new ErrorResponse { Ok = false, Error = msg, Db = db });
return Problem(msg, statusCode: 500);
}
return Ok(new { ok = true, db });
return Ok(new DatabaseResponse { Ok = true, Db = db });
}

// GET /v1/db/{db}/_all_docs?skip=&limit=&includeDeleted=
[HttpGet("{db}/_all_docs")]
[ProducesResponseType(typeof(AllDocsResponse), StatusCodes.Status200OK)]
public IActionResult AllDocs([FromRoute] string db, [FromQuery] int? skip, [FromQuery] int? limit, [FromQuery] bool? includeDeleted)
{
var docs = _svc.AllDocs(db, skip ?? 0, limit ?? 100, includeDeleted ?? true);
return Ok(new { total = docs?.Count() ?? 0, items = docs });
return Ok(new AllDocsResponse { Total = docs?.Count() ?? 0, Items = docs });
}

// GET /v1/db/{db}/_seq
[HttpGet("{db}/_seq")]
[ProducesResponseType(typeof(SequenceResponse), StatusCodes.Status200OK)]
public IActionResult Seq([FromRoute] string db)
{
var seq = _svc.Seq(db);
return Ok(new { db, seq });
return Ok(new SequenceResponse { Db = db, Seq = seq });
}

// ---- Поиск по полям ----
Expand All @@ -85,14 +123,15 @@ public sealed class FindByFieldsRequest

// POST /v1/db/{db}/_find/fields
[HttpPost("{db}/_find/fields")]
[ProducesResponseType(typeof(AllDocsResponse), StatusCodes.Status200OK)]
public IActionResult FindByFields([FromRoute] string db, [FromBody, Required] FindByFieldsRequest req)
{
(string field, double? min, double? max)? range = null;
if (!string.IsNullOrWhiteSpace(req.NumericField))
range = (req.NumericField!, req.Min, req.Max);

var docs = _svc.FindByFields(db, equals: req.EqualsMap, numericRange: range, skip: req.Skip ?? 0, limit: req.Limit ?? 100);
return Ok(new { total = docs?.Count() ?? 0, items = docs });
return Ok(new AllDocsResponse { Total = docs?.Count() ?? 0, Items = docs });
}

// ---- Поиск по тегам ----
Expand All @@ -107,19 +146,21 @@ public sealed class FindByTagsRequest

// POST /v1/db/{db}/_find/tags
[HttpPost("{db}/_find/tags")]
[ProducesResponseType(typeof(AllDocsResponse), StatusCodes.Status200OK)]
public IActionResult FindByTags([FromRoute] string db, [FromBody, Required] FindByTagsRequest req)
{
var docs = _svc.FindByTags(db, allOf: req.AllOf, anyOf: req.AnyOf, noneOf: req.NoneOf, skip: req.Skip ?? 0, limit: req.Limit ?? 100);
return Ok(new { total = docs?.Count() ?? 0, items = docs });
return Ok(new AllDocsResponse { Total = docs?.Count() ?? 0, Items = docs });
}

// ---- Полнотекст ----
// GET /v1/db/{db}/_search?q=...&skip=&limit=
[HttpGet("{db}/_search")]
[ProducesResponseType(typeof(AllDocsResponse), StatusCodes.Status200OK)]
public IActionResult FullText([FromRoute] string db, [FromQuery, Required] string q, [FromQuery] int? skip, [FromQuery] int? limit)
{
var docs = _svc.FullTextSearch(db, q, skip ?? 0, limit ?? 100);
return Ok(new { total = docs?.Count() ?? 0, items = docs });
return Ok(new AllDocsResponse { Total = docs?.Count() ?? 0, Items = docs });
}
}

Expand All @@ -128,6 +169,7 @@ public IActionResult FullText([FromRoute] string db, [FromQuery, Required] strin
// ===============================
[ApiController]
[Route("v1/db/{db}/docs")]
[Produces("application/json")]
public class DocsApiController : ControllerBase
{
private readonly IDocumentService _svc;
Expand All @@ -138,13 +180,22 @@ public DocsApiController(IDocumentService svc)
_svc = svc ?? throw new ArgumentNullException(nameof(svc));
}

// Model for multipart/form-data uploads (required for Swagger compatibility)
public sealed class BinaryUploadForm
{
[Required]
public IFormFile File { get; set; } = default!;
}

// GET /v1/db/{db}/docs/{id}?rev=
[HttpGet("{id}")]
[ProducesResponseType(typeof(Document), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
public IActionResult Get([FromRoute] string db, [FromRoute] string id, [FromQuery] string? rev = null)
{
var doc = _svc.Get(db, id, rev);
if (doc == null)
return NotFound(new { ok = false, error = "not found", db, id, rev });
return NotFound(new ErrorResponse { Ok = false, Error = "not found", Db = db, Id = id, Rev = rev });
return Ok(doc);
}

Expand All @@ -155,19 +206,23 @@ public IActionResult GetBinary([FromRoute] string db, [FromRoute] string id, [Fr
{
var doc = _svc.Get(db, id, rev);
if (doc == null)
return NotFound(new { ok = false, error = "not found", db, id, rev });
return NotFound(new ErrorResponse { Ok = false, Error = "not found", Db = db, Id = id, Rev = rev });
var bytes = CustomBinaryDocumentCodec.Serialize(doc);
return File(bytes, BinaryMime, fileDownloadName: id + ".pdoc");
}

// PUT /v1/db/{db}/docs/{id}
[HttpPut("{id}")]
[ProducesResponseType(typeof(Document), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public IActionResult Put([FromRoute] string db, [FromRoute] string id, [FromBody, Required] Document body)
{
var idProp = body.GetType().GetProperty("Id");
var bodyId = idProp?.GetValue(body)?.ToString();
if (bodyId != null && !string.Equals(bodyId, id, StringComparison.Ordinal))
return BadRequest(new { ok = false, error = "_id in body must equal route id", routeId = id, bodyId });
return BadRequest(new ErrorResponse { Ok = false, Error = "_id in body must equal route id", Db = db, Id = id });

if (bodyId == null && idProp?.CanWrite == true)
idProp.SetValue(body, id);
Expand All @@ -177,7 +232,7 @@ public IActionResult Put([FromRoute] string db, [FromRoute] string id, [FromBody
{
var msg = res.Error ?? "put failed";
if (msg.Contains("conflict", StringComparison.OrdinalIgnoreCase))
return Conflict(new { ok = false, error = msg, db, id });
return Conflict(new ErrorResponse { Ok = false, Error = msg, Db = db, Id = id });
return Problem(msg, statusCode: 500);
}

Expand All @@ -191,10 +246,12 @@ public IActionResult Put([FromRoute] string db, [FromRoute] string id, [FromBody
[HttpPut("{id}/bin")]
[Consumes(BinaryMime)]
[Produces(BinaryMime)]
public IActionResult PutBinary([FromRoute] string db, [FromRoute] string id)
public async Task<IActionResult> PutBinary([FromRoute] string db, [FromRoute] string id)
{
using var ms = new MemoryStream();
Request.Body.CopyTo(ms);
await Request.Body.CopyToAsync(ms, HttpContext.RequestAborted);
if (ms.Length == 0)
return BadRequest(new { ok = false, error = "request body is empty. Send binary payload with Content-Type 'application/x-peacedb-doc'", db, id });
var doc = CustomBinaryDocumentCodec.Deserialize(ms.ToArray());
if (!string.Equals(doc.Id, id, StringComparison.Ordinal) && !string.IsNullOrEmpty(doc.Id))
return BadRequest(new { ok = false, error = "_id in binary body must equal route id", routeId = id, bodyId = doc.Id });
Comment thread
timermakov marked this conversation as resolved.
Outdated
Comment thread
timermakov marked this conversation as resolved.
Outdated
Expand All @@ -204,7 +261,33 @@ public IActionResult PutBinary([FromRoute] string db, [FromRoute] string id)
{
var msg = res.Error ?? "put failed";
if (msg.Contains("conflict", StringComparison.OrdinalIgnoreCase))
return Conflict(new { ok = false, error = msg, db, id });
return Conflict(new ErrorResponse { Ok = false, Error = msg, Db = db, Id = id });
return Problem(msg, statusCode: 500);
}
var bytes = CustomBinaryDocumentCodec.Serialize(res.Doc!);
return File(bytes, BinaryMime, fileDownloadName: id + ".pdoc");
}

// PUT /v1/db/{db}/docs/{id}/bin-upload (multipart/form-data for Swagger file upload)
[HttpPut("{id}/bin-upload")]
[Consumes("multipart/form-data")]
[Produces(BinaryMime)]
public async Task<IActionResult> PutBinaryUpload([FromRoute] string db, [FromRoute] string id, [FromForm] BinaryUploadForm form)
{
if (form.File == null || form.File.Length == 0)
return BadRequest(new { ok = false, error = "file is required and cannot be empty" });
using var ms = new MemoryStream();
await form.File.CopyToAsync(ms, HttpContext.RequestAborted);
var doc = CustomBinaryDocumentCodec.Deserialize(ms.ToArray());
if (!string.Equals(doc.Id, id, StringComparison.Ordinal) && !string.IsNullOrEmpty(doc.Id))
return BadRequest(new { ok = false, error = "_id in binary body must equal route id", routeId = id, bodyId = doc.Id });
Comment thread
timermakov marked this conversation as resolved.
Outdated
Comment thread
timermakov marked this conversation as resolved.
Outdated
doc.Id = id;
var res = _svc.Put(db, doc);
if (!res.Ok)
{
var msg = res.Error ?? "put failed";
if (msg.Contains("conflict", StringComparison.OrdinalIgnoreCase))
return Conflict(new ErrorResponse { Ok = false, Error = msg, Db = db, Id = id });
return Problem(msg, statusCode: 500);
}
var bytes = CustomBinaryDocumentCodec.Serialize(res.Doc!);
Expand All @@ -228,11 +311,35 @@ public IActionResult Post([FromRoute] string db, [FromBody, Required] Document b
[HttpPost("bin")]
[Consumes(BinaryMime)]
[Produces(BinaryMime)]
public IActionResult PostBinary([FromRoute] string db)
public async Task<IActionResult> PostBinary([FromRoute] string db)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms, HttpContext.RequestAborted);
if (ms.Length == 0)
return BadRequest(new { ok = false, error = "request body is empty. Send binary payload with Content-Type 'application/x-peacedb-doc'", db });
Comment thread
timermakov marked this conversation as resolved.
Outdated
var doc = CustomBinaryDocumentCodec.Deserialize(ms.ToArray());
// For create, ignore incoming _rev to satisfy service contract
doc.Rev = null;
var res = _svc.Post(db, doc);
if (!res.Ok || res.Doc == null) return Problem(res.Error ?? "post failed", statusCode: 500);
var bytes = CustomBinaryDocumentCodec.Serialize(res.Doc);
var location = $"/v1/db/{db}/docs/{res.Doc.Id}/bin";
return File(bytes, BinaryMime, fileDownloadName: res.Doc.Id + ".pdoc");
}

// POST /v1/db/{db}/docs/bin-upload (multipart/form-data for Swagger file upload)
[HttpPost("bin-upload")]
[Consumes("multipart/form-data")]
[Produces(BinaryMime)]
public async Task<IActionResult> PostBinaryUpload([FromRoute] string db, [FromForm] BinaryUploadForm form)
{
if (form.File == null || form.File.Length == 0)
return BadRequest(new { ok = false, error = "file is required and cannot be empty" });
Comment thread
timermakov marked this conversation as resolved.
Outdated
using var ms = new MemoryStream();
Request.Body.CopyTo(ms);
await form.File.CopyToAsync(ms, HttpContext.RequestAborted);
var doc = CustomBinaryDocumentCodec.Deserialize(ms.ToArray());
// For create, ignore incoming _rev to satisfy service contract
doc.Rev = null;
var res = _svc.Post(db, doc);
if (!res.Ok || res.Doc == null) return Problem(res.Error ?? "post failed", statusCode: 500);
var bytes = CustomBinaryDocumentCodec.Serialize(res.Doc);
Expand Down Expand Up @@ -265,7 +372,8 @@ public IActionResult Delete([FromRoute] string db, [FromRoute] string id, [FromQ
// Бенчмарк бинарной vs JSON сериализации
// ===============================
[ApiController]
[Route("v1/bench")]
[Route("v1/bench")]
[Produces("application/json")]
public sealed class BenchApiController : ControllerBase
{
private readonly IDocumentService _svc;
Expand Down
1 change: 1 addition & 0 deletions PeaceDatabase/Storage/InMemoryDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ private bool TryGetDb(string db, out DbState state)
}
else
{
// TODO: обсудить
if (!string.IsNullOrEmpty(doc.Rev))
return (false, null, "New document must not provide _rev");
}
Expand Down
39 changes: 38 additions & 1 deletion PeaceDatabase/WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
o.JsonSerializerOptions.WriteIndented = false;
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
o.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
o.JsonSerializerOptions.MaxDepth = 64;
});

// ---------- Логирование ----------
Expand Down Expand Up @@ -78,7 +80,41 @@
});

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "PeaceDatabase API", Version = "v1" });

// Configure for complex Dictionary<string, object> types
c.UseAllOfToExtendReferenceSchemas();
c.UseOneOfForPolymorphism();

// Custom schema mapping for Dictionary<string, object>
c.MapType<Dictionary<string, object>>(() => new Microsoft.OpenApi.Models.OpenApiSchema
{
Type = "object",
AdditionalPropertiesAllowed = true,
AdditionalProperties = new Microsoft.OpenApi.Models.OpenApiSchema
{
Type = "object"
}
});
// Generic mapping for 'object' to avoid schema generation failures for open content
c.MapType<object>(() => new Microsoft.OpenApi.Models.OpenApiSchema
{
Type = "object"
});

// Include XML comments if available
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}

// Be more permissive with schema generation
c.CustomSchemaIds(type => type.FullName?.Replace("+", "."));
});

// ---------- Метрики ----------
var meter = new Meter("PeaceDatabase.WebApi", "1.0.0");
Expand Down Expand Up @@ -211,6 +247,7 @@
.Produces(StatusCodes.Status200OK)
.WithTags("_introspect");


// ------------------------------------------------------------
// 2) Фактические маршруты из EndpointDataSource (включая Minimal API)
// ------------------------------------------------------------
Expand Down
Loading