API & Mutation Engine
The Phoenix API provides a rich querying syntax for filtering and retrieving data, a powerful mutation engine for creating, updating, and deleting records with partial-object patching, and a standardized approach to endpoint creation and authentication.
API Querying Overview
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:
Pagination
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.
Filtering
Exact Matching
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".
Multi-Matching
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.
String Comparison
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".
Range Matching
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.
Collection Queries
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.
Tag Matching
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.
table_chart Query Parameter Reference
| Pattern | Behavior | Example |
|---|---|---|
| column=value | Exact match | typeId=GENERAL |
| column=v1&column=v2 | Multi-match (OR) | id=1&id=2 |
| columnStartsWith=x | String prefix | nameStartsWith=laser |
| columnEndsWith=x | String suffix | nameEndsWith=Pro |
| columnContains=x | String contains | nameContains=widget |
| columnMin=x | Range minimum | priceMin=10 |
| columnMax=x | Range maximum | priceMax=100 |
| collectionAnyProp=x | Collection any | categoriesAnyPrimaryId=5 |
| tags.key=value | Tag match | tags.color=red |
Query Inversion
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.
Includes
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": []
}
Arbitrary Depth with Dot Delimiter
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.
Sorting
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),
})
Mutation Engine
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.
compare_arrows Mutation Operations ($op)
| Operation | Behavior | If Not Found |
|---|---|---|
| upsert (default) | Resolve existing record; update if found, create if not | Creates new record |
| update | Resolve existing record and apply changes | Returns error |
| create | Create a new record with the supplied data | N/A (always creates; duplicates may throw constraint errors) |
| delete | Resolve existing record and delete it | Returns error |
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.
Nested Mutations
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."
}
}
]
}
What this payload does:
-
1
Updates the customer's
TaxExemptionNumberto "ABC123" -
2
Deactivates the existing contact
DEALER-1-ADDR-1by settingIsActiveto false -
3
Creates a new contact
DEALER-1-ADDR-3with a nested address record, all in one request
Lookups
When interfacing with related entities (foreign keys), you have several options for mapping values, each with different trade-offs between performance and flexibility.
Direct FK Assignment
FastestDirectly 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"
}
Key / ID Lookup
BalancedProvide 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
}
]
}
Upsert Lookup
Most FlexibleProvide 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" }
}
]
}
compare Lookup Strategy Comparison
| Strategy | Syntax | Performance | Validation |
|---|---|---|---|
| Direct FK | "TypeId": "Customer" | Fastest | None (DB constraint only) |
| Key/ID Lookup | "Product": "SKU" | Fast (cached) | Validates existence |
| Upsert Lookup | "Product": { "Key": "SKU" } | Slower | Full mutation pass; creates if missing |
Creating Endpoints
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.
star Controllers (Preferred)
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 API (alternative)
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
});
}
}
Endpoint Authentication
PhoenixController configures all endpoints to require at least being logged in by default. You can customize access control with the following attributes.
[AllowAnonymous] Public Access
Allows any unauthenticated user to access an endpoint. Commonly needed for public-facing endpoints like product catalogs and detail pages.
[Authorize(Roles = "...")] Role Requirements
Requires a specific role to access the endpoint.
[HttpGet]
[Route("/secure/route")]
[Authorize(Roles = "Global Admin")]
public async Task<ActionResult<SomeSecureData>> Get()
{
// ...
}
[PermissionAuthorize("...")] Permission Requirements
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()
{
// ...
}
Public
[AllowAnonymous]
No authentication required
Role-Based
[Authorize(Roles)]
Specific role membership
Permission-Based
[PermissionAuthorize]
Granular CRUD permissions