Query patterns, mutation engine architecture, filtering, sorting, and endpoint conventions.
List endpoints support a rich filtering syntax that enables sophisticated data retrieval without custom endpoint logic. All standard list endpoints automatically support the following capabilities when they accept a QueryFilters parameter:
Providing pageSize and pageIndex parameters applies pagination to the result set. The page index is 0-based, so the first page is index 0.
GET /products/product?pageSize=12&pageIndex=0
Returns the first page of 12 products. To retrieve the second page, use pageIndex=1.
Providing a query parameter whose name exactly matches a database column (case-insensitive) will attempt to match the value exactly.
GET /products/product?typeId=GENERAL
Returns only products whose TypeId column is "GENERAL".
Providing multiple instances of the same query parameter key matches against any of the provided values (OR logic).
GET /products/product?id=1&id=2&id=3
Returns products with IDs 1, 2, and 3.
Querying against string properties supports comparisons via StartsWith, EndsWith, or Contains suffixes appended to the column name.
GET /products/product?nameStartsWith=laser
Returns all products whose Name column begins with "laser".
For any range-comparable column (numerical types, dates, etc.), you can provide a Min and/or Max suffix to search against ranges of data.
GET /products/product?updatedDateMin=2024-11-28T00:00:00
Returns all products updated since the provided date.
Basic querying against collections is supported using Any, All, and Count operators. Any queries support string comparison and multi-matching. Count queries support range matching.
GET /products/product?categoriesAnyPrimaryId=5
Returns all products that are assigned to Category ID 5.
For records that support tags, you can query against tag values using the tags. prefix. Multi-matching across tag keys is fully supported.
GET /products/product?tags.color=red&tags.size=large
Matches any product whose Tags JSON contains a color tag of "red" and a size tag of "large".
Omitting a value will simply check if the tag exists, regardless of its value: ?tags.color= returns any product with a color tag.
All of the query operations described above support inversion by prefixing the query parameter with Not (case-insensitive). This inverts the filter logic, excluding matching records instead of including them.
GET /products/product?notNameStartsWith=laser
Returns all products whose Name does NOT begin with "laser".
GET /products/product?notTags.color=blue
Returns all products that either have no color tag, or their color tag's value is not "blue".
GET /products/product?notTags.color=
Returns all products that do not have a color tag at all.
By default, list and single-get endpoints return only the properties of the base entity itself. The include parameter allows you to request any nested objects to be populated in the response.
GET /products/product
{
"Id": 1,
"Key": "Sample",
"Categories": [],
"Prices": []
}
GET /products/product?include=Categories
{
"Id": 1,
"Key": "Sample",
"Categories": [
{
"Id": 1,
"PrimaryId": 5,
"SecondaryId": 1
}
],
"Prices": []
}
Supports arbitrary depth using the . character as a delimiter, and multiple include parameters to include different nested paths.
GET /products/product?include=Categories.Primary&include=Prices.Currency
{
"Id": 1,
"Key": "Sample",
"Categories": [
{
"Id": 1,
"PrimaryId": 5,
"Primary": {
"Id": 5,
"Key": "CATEGORY-KEY",
"ParentId": 3,
"Name": "Electronics"
},
"SecondaryId": 1
}
],
"Prices": [
{
"Id": 1,
"Price": 5,
"CurrencyId": 1,
"Currency": {
"Id": 1,
"Key": "USD",
"Name": "United States Dollar",
"Symbol": "$"
}
}
]
}
Case-Sensitive Paths
The path of your include parameter IS case-sensitive. This is due to the underlying Entity Framework Include implementation. The include keyword itself is case-insensitive.
Sort query results by passing a sort parameter in the format sort.column=asc|desc. Multiple sort parameters are applied in the order they appear in the query string, enabling multi-level sorting.
GET /products/product?sort.name=asc
Returns products sorted by Name in ascending order.
GET /products/product?sort.typeid=desc&sort.name=asc
First sorts by TypeId descending, then by Name ascending within each group.
api.ListLogEntry({
PageIndex: 0,
PageSize: 16,
"Sort.LoggedAt": "desc",
...Object.fromEntries(params),
})
The mutation engine handles all create, update, upsert, and delete operations for entities via the API. It enables partial object patching, meaning you only need to supply the properties you intend to change. Each object supplied to the mutator should contain an identifier (Key or Id), the properties to update, and optionally an operation hint.
{
"$op": "update",
"Key": "SKU-OF-PRODUCT",
"Name": "New Name"
}
Equivalent to: UPDATE [Products].[Product] SET [Name] = N'New Name' WHERE [Key] = 'SKU-OF-PRODUCT'
[
{
"$op": "update",
"Key": "SKU-OF-PRODUCT",
"Name": "New Name"
},
{
"$op": "update",
"Key": "OTHER-SKU",
"Name": "Different New Name",
"Description": "A new description on this one"
}
]
Each object can be asymmetrical -- you do not have to define the same properties on every object. You can even supply different operations for each item.
Performance Tip
Avoid supplying every property of an object. Only pass properties you intend to update. The larger your DTO, the more work the mutation engine does to assign values for each property, and the longer the operation takes. Streamlining the DTO also makes clear what you intend to change and avoids potentially altering fields you do not.
The mutation engine supports updating related and associated records within a single payload. This reduces round trips to and from the API, which by extension reduces round trips to the database. Each nested object follows the same mutation rules, including independent operation hints.
{
"Key": "DEALER-1",
"TaxExemptionNumber": "ABC123",
"CustomerContacts": [
{
"$op": "update",
"Key": "DEALER-1-ADDR-1",
"IsActive": false
},
{
"$op": "create",
"Key": "DEALER-1-ADDR-3",
"Name": "LA Warehouse",
"Secondary": {
"$op": "create",
"Key": "DEALER-1-ADDR-3",
"Street1": "123 Warehouse St."
}
}
]
}
TaxExemptionNumber to "ABC123"
DEALER-1-ADDR-1 by setting IsActive to false
DEALER-1-ADDR-3 with a nested address record, all in one request
When interfacing with related entities (foreign keys), you have several options for mapping values, each with different trade-offs between performance and flexibility.
Directly set the foreign key property if you are certain of the identifier. Bypasses all lookups and validation in the mutation engine. If the key does not exist in the target table, you will get a foreign key constraint error.
{
"Key": "Some Customer",
"TypeId": "Customer"
}
Provide a value to the navigation property (not the FK property) and the mutation engine runs a lookup. Lookups are internally cached, so repeat calls for the same data in a single run are fairly quick. Returns an error if the key/ID does not resolve.
{
"Key": "ORDER-123",
"Items": [
{
"$op": "create",
"Key": "ITEM-1",
"Product": "SOME-PRODUCT-SKU"
},
{
"$op": "create",
"Key": "ITEM-2",
"Product": 123
}
]
}
Provide an object with exclusively an identifier. The nested object goes through the full mutation process: check operation (defaults to upsert), pick the identifier, see if it resolves, and if not, create a new record. This ensures the related object exists but comes at a higher performance cost.
{
"Key": "ORDER-123",
"Items": [
{
"$op": "create",
"Key": "ITEM-1",
"Product": { "Key": "SOME-PRODUCT-SKU" },
"Type": { "Key": "LocalTax" }
}
]
}
The Phoenix backend is built on ASP.NET Core. Endpoints are standard ASP.NET Core functionality with two primary approaches: Controllers (preferred) and Minimal APIs.
Controllers are the preferred method of creating endpoints. Define a class marked with the [ApiController] attribute and derive from PhoenixController.
[ApiController]
public class SampleController : PhoenixController
{
[HttpGet]
[Route("/sample", Name = nameof(GetModel))]
public async Task<ActionResult<SomeModel>> GetModel()
{
// ...
}
}
// Request body
[HttpPost]
[Route("/sample/with-body")]
public async Task<ActionResult<SomeModel>> SomeAction(
[FromBody] SomeModel input) { }
// Route parameter
[HttpGet]
[Route("/sample/{id}")]
public async Task<ActionResult<SomeModel>> GetById(
[FromRoute] int id) { }
// Query parameter
[HttpGet]
[Route("/sample/with-query")]
public async Task<ActionResult<SomeModel>> Get(
[FromQuery] string value) { }
// Dependency injection
[HttpGet]
[Route("/sample/with-injection")]
public async Task<ActionResult<SomeModel>> GetSomething(
[FromServices] IPipelineContext context) { }
[HttpGet]
[Route("list-some-data")]
public async Task<ActionResult<List<SomeModel>>> ListSomeData(
QueryFilters query,
[FromServices] IPipelineContext context)
{
return await ListModelsPipeline<SomeEntity, SomeModel>
.ExecuteAsync(query, context);
}
Do not include [FromQuery] or any other parameter binding attribute on the QueryFilters parameter. Its binding is handled automatically by the platform. Adding these attributes interferes with that process.
Minimal APIs should only be used if the Controller approach is not feasible (such as generic endpoint controllers). Configure them via the OnStartup hook in your plugin file.
public class MyPlugin : Plugin
{
public override void OnStartup(WebApplication app)
{
app.MapGet("/sample/minimal-api", () => "Hello!");
app.MapGet("/sample/{id}",
([FromRoute] int id) => /* ... */);
app.MapPost("/sample",
async ([FromServices] IPipelineContext context) =>
{
// Do stuff
});
}
}
PhoenixController configures all endpoints to require at least being logged in by default. You can customize access control with the following attributes.
Allows any unauthenticated user to access an endpoint. Commonly needed for public-facing endpoints like product catalogs and detail pages.
Requires a specific role to access the endpoint.
[HttpGet]
[Route("/secure/route")]
[Authorize(Roles = "Global Admin")]
public async Task<ActionResult<SomeSecureData>> Get()
{
// ...
}
Requires specific granular permissions to access the endpoint. Permissions follow the Schema.Table.Operation convention, where Operation is one of C (Create), R (Read), U (Update), D (Delete).
[HttpPost]
[Route("/secure/create")]
[PermissionAuthorize("Schema.Table.C")]
public async Task<ActionResult<SomeSecureData>> Create()
{
// ...
}
[AllowAnonymous]
No authentication required
[Authorize(Roles)]
Specific role membership
[PermissionAuthorize]
Granular CRUD permissions
The mutation engine supports deep object graphs in a single API call. When creating or updating an entity, you can include nested child objects that are created, updated, or deleted in the same transaction. The engine resolves relationships, sets foreign keys, and handles ordering automatically. This eliminates the need for multiple API calls to manage parent-child relationships. See Nested Mutations.
Query inversion allows clients to request exactly the data they need by specifying includes, filters, sorts, and pagination in the query parameters. Instead of the server dictating the response shape, the client controls which navigation properties are included and how data is filtered. This reduces over-fetching and eliminates the need for multiple specialized endpoints. See Query Inversion.
All API endpoints require authentication by default via JWT bearer tokens. Authorization is enforced through the role-based permission system, which checks entity-level and operation-level permissions (read, create, update, delete) before executing any query or mutation. Unauthenticated requests receive a 401 response; unauthorized requests receive a 403. A small set of public endpoints (authentication, Quick Pay token validation) are explicitly whitelisted. See API Security.