Skip to content

Commit 9be7fd3

Browse files
committed
chore: adds sample app
1 parent a672cb7 commit 9be7fd3

7 files changed

Lines changed: 406 additions & 0 deletions

File tree

BLite.Server.code-workspace

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"folders": [
3+
{
4+
"path": "."
5+
},
6+
{
7+
"path": "../BLite"
8+
}
9+
],
10+
"settings": {}
11+
}

BLite.Server.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
<Folder Name="/Elementi di soluzione/">
33
<File Path="AGENTS.md" />
44
</Folder>
5+
<Folder Name="/samples/">
6+
<Project Path="samples/BLite.Sample/BLite.Sample.csproj" />
7+
</Folder>
58
<Folder Name="/src/">
69
<Project Path="src/BLite.Client/BLite.Client.csproj" />
710
<Project Path="src/BLite.Proto/BLite.Proto.csproj" />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<RootNamespace>BLite.Sample</RootNamespace>
9+
<AssemblyName>BLite.Sample</AssemblyName>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.*" />
14+
<PackageReference Include="Scalar.AspNetCore" Version="2.*" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\..\src\BLite.Client\BLite.Client.csproj" />
19+
</ItemGroup>
20+
21+
</Project>

samples/BLite.Sample/Program.cs

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// BLite.Sample — Product-Catalog REST API
2+
// Demonstrates BLite.Client usage against a local BLite Server instance.
3+
//
4+
// Pre-requisites:
5+
// 1. A running BLite Server (dotnet run --project src/BLite.Server)
6+
// 2. A valid API key in appsettings.Development.json → BLite:ApiKey
7+
//
8+
// Endpoints (all documented in Scalar at /scalar/v1):
9+
// POST /products — insert a new product
10+
// GET /products — list products (optional ?limit=N, default 50)
11+
// GET /products/{id} — find by ObjectId hex
12+
// PUT /products/{id} — update an existing product (partial)
13+
// DELETE /products/{id} — delete a product
14+
// POST /products/search — server-side filter / sort / paging
15+
16+
using System.Text.Json;
17+
using System.Text.Json.Nodes;
18+
using BLite.Bson;
19+
using BLite.Client;
20+
using BLite.Proto;
21+
using Microsoft.AspNetCore.Http.HttpResults;
22+
using Scalar.AspNetCore;
23+
24+
// ── Builder ───────────────────────────────────────────────────────────────────
25+
26+
var builder = WebApplication.CreateBuilder(args);
27+
28+
// BLiteClient is a singleton — reuse the GrpcChannel across all requests.
29+
builder.Services.AddSingleton(sp =>
30+
{
31+
var opts = builder.Configuration
32+
.GetSection("BLite")
33+
.Get<BLiteClientOptions>()
34+
?? throw new InvalidOperationException(
35+
"Missing 'BLite' configuration section in appsettings.json. " +
36+
"Set Host, Port, ApiKey and UseTls.");
37+
return new BLiteClient(opts);
38+
});
39+
40+
builder.Services.AddOpenApi();
41+
42+
// ── App ───────────────────────────────────────────────────────────────────────
43+
44+
var app = builder.Build();
45+
46+
app.MapOpenApi(); // /openapi/v1.json
47+
app.MapScalarApiReference(options =>
48+
{
49+
options.Title = "BLite Sample — Products API";
50+
}); // /scalar/v1
51+
52+
// ── Internal constants ────────────────────────────────────────────────────────
53+
54+
const string Collection = "products";
55+
string[] ProductFields = ["name", "category", "price", "stock", "active", "createdAt"];
56+
57+
// ── Mapping helpers ───────────────────────────────────────────────────────────
58+
59+
static bool TryParseObjectId(string hex, out BsonId id)
60+
{
61+
if (hex.Length == 24)
62+
{
63+
try
64+
{
65+
id = new BsonId(new ObjectId(Convert.FromHexString(hex)));
66+
return true;
67+
}
68+
catch { /* invalid bytes */ }
69+
}
70+
id = default;
71+
return false;
72+
}
73+
74+
static ProductResponse? DocToProduct(BsonDocument doc)
75+
{
76+
if (!doc.TryGetId(out var bsonId)) return null;
77+
doc.TryGetString("name", out var name);
78+
doc.TryGetString("category", out var category);
79+
var price = doc.TryGetValue("price", out var pv) ? pv.AsDouble : 0d;
80+
var stock = doc.TryGetInt32("stock", out var sv) ? sv : 0;
81+
var active = doc.TryGetValue("active", out var av) ? av.AsBoolean : true;
82+
var createdAt = doc.TryGetValue("createdAt", out var dv) ? dv.AsDateTime : DateTime.MinValue;
83+
return new ProductResponse(bsonId.ToString(), name ?? "", category ?? "", price, stock, active, createdAt);
84+
}
85+
86+
static ScalarValue ToScalarValue(JsonNode? json)
87+
{
88+
if (json is not JsonValue jv) return ScalarValue.Null();
89+
var elem = jv.GetValue<JsonElement>();
90+
return elem.ValueKind switch
91+
{
92+
JsonValueKind.True => ScalarValue.From(true),
93+
JsonValueKind.False => ScalarValue.From(false),
94+
JsonValueKind.Number => elem.TryGetInt32(out var i32) ? ScalarValue.From(i32) :
95+
elem.TryGetInt64(out var i64) ? ScalarValue.From(i64) :
96+
ScalarValue.From(elem.GetDouble()),
97+
JsonValueKind.String => ScalarValue.From(elem.GetString()!),
98+
_ => ScalarValue.Null()
99+
};
100+
}
101+
102+
static FilterOp ParseOp(string op) => op.ToLowerInvariant() switch
103+
{
104+
"eq" => FilterOp.Eq,
105+
"neq" or "ne" => FilterOp.NotEq,
106+
"lt" => FilterOp.Lt,
107+
"lte" or "lteq" => FilterOp.LtEq,
108+
"gt" => FilterOp.Gt,
109+
"gte" or "gteq" => FilterOp.GtEq,
110+
"startswith" => FilterOp.StartsWith,
111+
"contains" => FilterOp.Contains,
112+
_ => throw new ArgumentException(
113+
$"Unknown op '{op}'. Valid: eq / neq / lt / lte / gt / gte / startsWith / contains.")
114+
};
115+
116+
// ── POST /products ────────────────────────────────────────────────────────────
117+
118+
app.MapPost("/products", async Task<Created<ProductIdResponse>> (
119+
CreateProductRequest req,
120+
BLiteClient client,
121+
CancellationToken ct) =>
122+
{
123+
var col = client.GetDynamicCollection(Collection);
124+
var doc = await col.NewDocumentAsync(ProductFields, b => b
125+
.AddString ("name", req.Name)
126+
.AddString ("category", req.Category)
127+
.AddDouble ("price", req.Price)
128+
.AddInt32 ("stock", req.Stock)
129+
.AddBoolean ("active", req.Active)
130+
.AddDateTime("createdAt", DateTime.UtcNow), ct);
131+
132+
var id = await col.InsertAsync(doc, ct: ct);
133+
return TypedResults.Created($"/products/{id}", new ProductIdResponse(id.ToString()));
134+
})
135+
.WithName("CreateProduct")
136+
.WithSummary("Insert a new product into the catalog.")
137+
.WithTags("Products");
138+
139+
// ── GET /products ─────────────────────────────────────────────────────────────
140+
141+
app.MapGet("/products", async Task<Ok<List<ProductResponse>>> (
142+
int? limit,
143+
BLiteClient client,
144+
CancellationToken ct) =>
145+
{
146+
var col = client.GetDynamicCollection(Collection);
147+
var descriptor = new QueryDescriptor { Take = Math.Min(limit ?? 50, 500) };
148+
var result = new List<ProductResponse>();
149+
await foreach (var doc in col.QueryAsync(descriptor, ct))
150+
{
151+
var p = DocToProduct(doc);
152+
if (p is not null) result.Add(p);
153+
}
154+
return TypedResults.Ok(result);
155+
})
156+
.WithName("ListProducts")
157+
.WithSummary("Return up to 'limit' products (default 50, max 500).")
158+
.WithTags("Products");
159+
160+
// ── GET /products/{id} ────────────────────────────────────────────────────────
161+
162+
app.MapGet("/products/{id}", async Task<Results<Ok<ProductResponse>, NotFound, BadRequest<string>>> (
163+
string id,
164+
BLiteClient client,
165+
CancellationToken ct) =>
166+
{
167+
if (!TryParseObjectId(id, out var bsonId))
168+
return TypedResults.BadRequest("'id' must be a 24-char lowercase hex ObjectId.");
169+
170+
var col = client.GetDynamicCollection(Collection);
171+
var doc = await col.FindByIdAsync(bsonId, ct);
172+
173+
if (doc is null) return TypedResults.NotFound();
174+
var p = DocToProduct(doc);
175+
return p is not null ? TypedResults.Ok(p) : (Results<Ok<ProductResponse>, NotFound, BadRequest<string>>)TypedResults.NotFound();
176+
})
177+
.WithName("GetProduct")
178+
.WithSummary("Find a product by its ObjectId.")
179+
.WithTags("Products");
180+
181+
// ── PUT /products/{id} ────────────────────────────────────────────────────────
182+
183+
app.MapPut("/products/{id}", async Task<Results<Ok<ProductIdResponse>, NotFound, BadRequest<string>>> (
184+
string id,
185+
UpdateProductRequest req,
186+
BLiteClient client,
187+
CancellationToken ct) =>
188+
{
189+
if (!TryParseObjectId(id, out var bsonId))
190+
return TypedResults.BadRequest("'id' must be a 24-char lowercase hex ObjectId.");
191+
192+
var col = client.GetDynamicCollection(Collection);
193+
var doc = await col.NewDocumentAsync(ProductFields, b =>
194+
{
195+
if (req.Name is not null) b.AddString ("name", req.Name);
196+
if (req.Category is not null) b.AddString ("category", req.Category);
197+
if (req.Price is not null) b.AddDouble ("price", req.Price.Value);
198+
if (req.Stock is not null) b.AddInt32 ("stock", req.Stock.Value);
199+
if (req.Active is not null) b.AddBoolean ("active", req.Active.Value);
200+
}, ct);
201+
202+
var ok = await col.UpdateAsync(bsonId, doc, ct: ct);
203+
return ok
204+
? TypedResults.Ok(new ProductIdResponse(id))
205+
: (Results<Ok<ProductIdResponse>, NotFound, BadRequest<string>>)TypedResults.NotFound();
206+
})
207+
.WithName("UpdateProduct")
208+
.WithSummary("Update fields of an existing product (omit unchanged fields).")
209+
.WithTags("Products");
210+
211+
// ── DELETE /products/{id} ─────────────────────────────────────────────────────
212+
213+
app.MapDelete("/products/{id}", async Task<Results<Ok<DeletedResponse>, NotFound, BadRequest<string>>> (
214+
string id,
215+
BLiteClient client,
216+
CancellationToken ct) =>
217+
{
218+
if (!TryParseObjectId(id, out var bsonId))
219+
return TypedResults.BadRequest("'id' must be a 24-char lowercase hex ObjectId.");
220+
221+
var col = client.GetDynamicCollection(Collection);
222+
var ok = await col.DeleteAsync(bsonId, ct: ct);
223+
return ok
224+
? TypedResults.Ok(new DeletedResponse(id))
225+
: (Results<Ok<DeletedResponse>, NotFound, BadRequest<string>>)TypedResults.NotFound();
226+
})
227+
.WithName("DeleteProduct")
228+
.WithSummary("Delete a product by its ObjectId.")
229+
.WithTags("Products");
230+
231+
// ── POST /products/search ─────────────────────────────────────────────────────
232+
233+
app.MapPost("/products/search", async Task<Results<Ok<List<ProductResponse>>, BadRequest<string>>> (
234+
SearchRequest req,
235+
BLiteClient client,
236+
CancellationToken ct) =>
237+
{
238+
var descriptor = new QueryDescriptor
239+
{
240+
Skip = req.Skip ?? 0,
241+
Take = Math.Min(req.Take ?? 50, 500)
242+
};
243+
244+
if (req.Field is not null && req.Op is not null)
245+
{
246+
FilterOp op;
247+
try { op = ParseOp(req.Op); }
248+
catch (ArgumentException ex) { return TypedResults.BadRequest(ex.Message); }
249+
descriptor.Where = new BinaryFilter { Field = req.Field, Op = op, Value = ToScalarValue(req.Value) };
250+
}
251+
252+
if (req.OrderBy is not null)
253+
descriptor.OrderBy.Add(new SortSpec { Field = req.OrderBy, Descending = req.Descending ?? false });
254+
255+
var col = client.GetDynamicCollection(Collection);
256+
var result = new List<ProductResponse>();
257+
await foreach (var doc in col.QueryAsync(descriptor, ct))
258+
{
259+
var p = DocToProduct(doc);
260+
if (p is not null) result.Add(p);
261+
}
262+
return TypedResults.Ok(result);
263+
})
264+
.WithName("SearchProducts")
265+
.WithSummary("Server-side filter, sort and paging.")
266+
.WithDescription("""
267+
Filter a single field with one of: eq, neq, lt, lte, gt, gte, startsWith, contains.
268+
All fields are optional — omit filter/orderBy to list all.
269+
270+
Example body:
271+
{
272+
"field": "category",
273+
"op": "eq",
274+
"value": "electronics",
275+
"orderBy": "price",
276+
"descending": false,
277+
"skip": 0,
278+
"take": 20
279+
}
280+
""")
281+
.WithTags("Products");
282+
283+
// ── Run ───────────────────────────────────────────────────────────────────────
284+
285+
app.Run();
286+
287+
// ── Response models ───────────────────────────────────────────────────────────
288+
289+
record ProductResponse(
290+
string Id,
291+
string Name,
292+
string Category,
293+
double Price,
294+
int Stock,
295+
bool Active,
296+
DateTime CreatedAt);
297+
298+
record ProductIdResponse(string Id);
299+
300+
record DeletedResponse(string Id);
301+
302+
// ── Request models ────────────────────────────────────────────────────────────
303+
304+
record CreateProductRequest(
305+
string Name,
306+
string Category,
307+
double Price,
308+
int Stock,
309+
bool Active = true);
310+
311+
record UpdateProductRequest(
312+
string? Name = null,
313+
string? Category = null,
314+
double? Price = null,
315+
int? Stock = null,
316+
bool? Active = null);
317+
318+
/// <summary>
319+
/// Server-side search request.
320+
/// Value accepts any JSON scalar (string, number, bool).
321+
/// </summary>
322+
record SearchRequest(
323+
string? Field = null,
324+
string? Op = null,
325+
JsonNode? Value = null,
326+
string? OrderBy = null,
327+
bool? Descending = null,
328+
int? Skip = null,
329+
int? Take = null);
330+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"profiles": {
3+
"BLite.Sample": {
4+
"commandName": "Project",
5+
"launchBrowser": true,
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
},
9+
"applicationUrl": "https://localhost:55221;http://localhost:55222"
10+
}
11+
}
12+
}

0 commit comments

Comments
 (0)