Clarity Platform Architecture / Core Concepts / API & Mutation Engine
api

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.

search

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:

check_circle Pagination
check_circle Exact match filtering
check_circle Multi-match (OR) filtering
check_circle String comparison (StartsWith, EndsWith, Contains)
check_circle Range matching (Min/Max for numbers and dates)
check_circle Collection queries (Any, All, Count)
check_circle Tag matching
check_circle Query inversion (Not prefix)
check_circle Optional nested object includes
check_circle Multi-level sorting
pages

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.

Example Request
GET /products/product?pageSize=12&pageIndex=0

Returns the first page of 12 products. To retrieve the second page, use pageIndex=1.

filter_alt

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".

info

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
block

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.

Exclude by string prefix
GET /products/product?notNameStartsWith=laser

Returns all products whose Name does NOT begin with "laser".

Exclude by tag value
GET /products/product?notTags.color=blue

Returns all products that either have no color tag, or their color tag's value is not "blue".

Exclude by tag existence
GET /products/product?notTags.color=

Returns all products that do not have a color tag at all.

account_tree

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.

Without includes
GET /products/product
{
  "Id": 1,
  "Key": "Sample",
  "Categories": [],
  "Prices": []
}
With includes
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": "$"
      }
    }
  ]
}
warning

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

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.

Single sort
GET /products/product?sort.name=asc

Returns products sorted by Name in ascending order.

Multi-level sort
GET /products/product?sort.typeid=desc&sort.name=asc

First sorts by TypeId descending, then by Name ascending within each group.

JavaScript API client usage
api.ListLogEntry({
    PageIndex: 0,
    PageSize: 16,
    "Sort.LoggedAt": "desc",
    ...Object.fromEntries(params),
})
edit_note

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.

Basic update mutation
{
    "$op": "update",
    "Key": "SKU-OF-PRODUCT",
    "Name": "New Name"
}

Equivalent to: UPDATE [Products].[Product] SET [Name] = N'New Name' WHERE [Key] = 'SKU-OF-PRODUCT'

Batch mutation (array of objects)
[
    {
        "$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
tips_and_updates

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.

schema

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.

Update customer + deactivate contact + create new contact with nested address
{
    "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. 1 Updates the customer's TaxExemptionNumber to "ABC123"
  2. 2 Deactivates the existing contact DEALER-1-ADDR-1 by setting IsActive to false
  3. 3 Creates a new contact DEALER-1-ADDR-3 with a nested address record, all in one request
manage_search

Lookups

When interfacing with related entities (foreign keys), you have several options for mapping values, each with different trade-offs between performance and flexibility.

1

Direct FK Assignment

Fastest

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"
}
2

Key / ID Lookup

Balanced

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
        }
    ]
}
3

Upsert Lookup

Most Flexible

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" }
        }
    ]
}

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
terminal

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.

Basic Controller Setup
[ApiController]
public class SampleController : PhoenixController
{
    [HttpGet]
    [Route("/sample", Name = nameof(GetModel))]
    public async Task<ActionResult<SomeModel>> GetModel()
    {
        // ...
    }
}
Parameter Binding Attributes
// 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) { }
QueryFilters for full API filtering support
[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);
}
warning

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
        });
    }
}
shield

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

Public

[AllowAnonymous]

No authentication required

group

Role-Based

[Authorize(Roles)]

Specific role membership

key

Permission-Based

[PermissionAuthorize]

Granular CRUD permissions