Skip to content
IzzyBlazor 0.1.0-alpha.20

Enter all results ↑ ↓ move Esc close

Data from an API

In a WebAssembly or Auto app, the ItemsProvider runs in the browser, so it sends its DataRequest to an API. The request can travel in the URL of a GET, as JSON, or as OData query options. On your own server, the request applies only through the fields you declare.

Calling your own API

The client writes the request into the URL of a GET with ToQueryString(), and the server reads it back with DataRequest<T>.Parse. Then ToResultAsync, given the fields you declare, applies it to the query. Both sides use the same row type, here OrderRow in a project the client and the server share:

Orders.razor (client)
@inject HttpClient Api

<Table TItem="OrderRow" ItemsProvider="LoadOrders" PageSize="20">
    <PropertyColumn Property="o => o.Customer" Sortable Filterable />
    <PropertyColumn Property="o => o.Total" Format="C" Sortable Filterable />
</Table>

@code {
    async ValueTask<DataResult<OrderRow>> LoadOrders(DataRequest<OrderRow> request) =>
        (await Api.GetFromJsonAsync<DataResult<OrderRow>>(
            $"api/orders?{request.ToQueryString()}", request.CancellationToken))!;
}
Program.cs (server)
using Microsoft.EntityFrameworkCore;   // CountAsync, ToListAsync

app.MapGet("/api/orders", async (HttpContext context, ShopDb db) =>
{
    var request = DataRequest<OrderRow>.Parse(context.Request.QueryString.Value ?? "")
        with { CancellationToken = context.RequestAborted };
    // At most 100 rows.
    request = request with { Take = Math.Min(request.Take ?? 100, 100) };
    var rows = db.Orders.AsNoTracking()   // an initializer: EF Core filters and sorts on it
        .Select(o => new OrderRow { Id = o.Id, Customer = o.Customer.Name, Total = o.Total });
    return await request.ToResultAsync(rows, DataFields.Of<OrderRow>(),
        count: (q, ct) => q.CountAsync(ct), list: (q, ct) => q.ToListAsync(ct));
});

public sealed record OrderRow
{
    public int Id { get; init; }
    public string Customer { get; init; } = "";
    public decimal Total { get; init; }
}

The query string looks like sort=-placed&skip=20&take=10&filter=total ge 100 (shown unescaped). It adds total=false when the component doesn't need the count. To send the request in a POST instead, serialize it with System.Text.Json.

The request names fields by the client's column keys, so the server's fields need the same keys. With the same row type they match. A column on a path, such as o => o.Customer.Name, has the key customer.name, so give it the SortKey of the server's field. See Fields.

Filters as text

A filter has a text form, a subset of OData's $filter. It's the form the URL uses for filter=. A filter's ToString() writes it, and Filter.Parse reads it:

text
total ge 100 and (status in ('Paid','Pending') or customer contains 'ada')
WriteFor
eq ne gt ge lt leComparisons. eq null and ne null are the null checks.
status in ('Paid','Pending')Any of the values. not (status in (...)) is none of them.
customer contains 'ada'Text that contains the value. contains(customer,'ada') works too, as do startswith and endswith.
between(total,100,500)A range. Either end can be null, for no limit.
isempty(email)No value, or empty text. isnotempty is the opposite.
date(placed) eq 2026-10-28On that day.
and or not ( )Groups. and binds before or.
'O''Brien', -1.5, true, 2026-10-28T09:30:00ZValues: text in single quotes (with a quote doubled), numbers, true or false, dates and times, and Guids.

Parsing only builds the filter. Names are field keys and values stay values, so name eq 'x'' or 1 eq 1' looks for that exact text.

  • Text that isn't in this form doesn't parse, and TryParse returns false.
  • A request read from a URL with such a filter matches nothing. A skip or take that doesn't parse is left out.
  • Text ignores case here too, unlike OData's own contains.
  • The text form doesn't write MatchCase, so a condition sent this way ignores case on the server. To keep MatchCase, send the request as JSON. See Text and case.

OData endpoints

When the API is an OData endpoint, ToODataQuery() writes the request as its query options: $filter, $orderby, $skip, $top, $count and $search.

csharp
var url = $"odata/Orders?{request.ToODataQuery()}";
// Unescaped:
// $filter=contains(tolower(customer),'ada') and total ge 100
// &$orderby=placed desc&$skip=20&$top=10&$count=true
  • OData compares text with case. Text that ignores case is written lower-cased on both sides: contains(tolower(customer),'ada').
  • Field paths use /: customer/name.
  • Values are written as their fields' types. A Table's request carries its fields. For a request you read with Parse, pass the fields: ToODataQuery(fields).
  • Keys are lower case. If the service matches property names with case, give the columns a SortKey with the service's names.
  • OData has no cursor. Pass request.Cursor on yourself.

Limits and security

A request that comes from the browser can ask for anything, so the server decides what it can reach. These rules apply when you pass your own fields to ToResultAsync or Apply:

  • Only declared fields can be reached. A condition on a key that isn't a filterable field matches nothing, and a sort by one is left out. DataFields.Of<T>() declares every public property, so read it from a type made for the API, not from an entity with a password hash on it.
  • Cap the range. The request sets its own Take, or asks for every row without one. The server example above allows at most 100 rows.
  • Large or deep filters match nothing. A filter with more than 1,000 nodes (conditions, groups and nots), or nested more than 64 deep, matches no rows. The text form nests at most 32 deep.

Next steps

Connection lost

Rejoining the server…