Query Arguments
Controller-based queries can accept arguments to filter, customize, or parameterize the data they return. Arguments can come from route parameters, query strings, or request bodies.
💡 Proxy Generation: The proxy generator automatically analyzes your query arguments and creates strongly-typed TypeScript interfaces, ensuring type safety between your backend and frontend.
Route Parameters
Route parameters are embedded in the URL path and are typically used for primary identifiers:
[Route("api/accounts")]
public class Accounts : Controller
{
readonly IMongoCollection<DebitAccount> _collection;
public Accounts(IMongoCollection<DebitAccount> collection) => _collection = collection;
[HttpGet("{id}")]
public DebitAccount GetAccountById(AccountId id)
{
return _collection.Find(a => a.Id == id).FirstOrDefault();
}
[HttpGet("owner/{ownerId}")]
public IEnumerable<DebitAccount> GetAccountsByOwner(CustomerId ownerId)
{
return _collection.Find(a => a.Owner == ownerId).ToList();
}
[HttpGet("{id}/balance")]
public decimal GetAccountBalance(AccountId id)
{
var account = _collection.Find(a => a.Id == id).FirstOrDefault();
return account?.Balance ?? 0;
}
}
Query String Parameters
Query string parameters are appended to the URL after a ? and are typically used for optional filters or configuration:
[HttpGet]
public IEnumerable<DebitAccount> GetAccounts([FromQuery] string? nameFilter = null)
{
var filter = Builders<DebitAccount>.Filter.Empty;
if (!string.IsNullOrEmpty(nameFilter))
{
filter = Builders<DebitAccount>.Filter.Regex(
account => account.Name,
new BsonRegularExpression(nameFilter, "i"));
}
return _collection.Find(filter).ToList();
}
[HttpGet("search")]
public async Task<IEnumerable<DebitAccount>> SearchAccounts(
[FromQuery] string? name = null,
[FromQuery] decimal? minBalance = null,
[FromQuery] decimal? maxBalance = null,
[FromQuery] bool includeInactive = false)
{
var filterBuilder = Builders<DebitAccount>.Filter;
var filters = new List<FilterDefinition<DebitAccount>>();
if (!string.IsNullOrEmpty(name))
{
filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(name, "i")));
}
if (minBalance.HasValue)
{
filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));
}
if (maxBalance.HasValue)
{
filters.Add(filterBuilder.Lte(a => a.Balance, maxBalance.Value));
}
if (!includeInactive)
{
filters.Add(filterBuilder.Gt(a => a.Balance, 0));
}
var combinedFilter = filters.Any()
? filterBuilder.And(filters)
: filterBuilder.Empty;
var result = await _collection.FindAsync(combinedFilter);
return result.ToList();
}
Complex Query Objects
For complex queries with multiple parameters, you can create dedicated query objects:
public record AccountSearchQuery(
string? Name,
decimal? MinBalance,
decimal? MaxBalance,
bool IncludeInactive,
string? OwnerName);
[HttpGet("advanced-search")]
public IEnumerable<DebitAccount> SearchAccountsAdvanced([FromQuery] AccountSearchQuery query)
{
var filterBuilder = Builders<DebitAccount>.Filter;
var filters = new List<FilterDefinition<DebitAccount>>();
if (!string.IsNullOrEmpty(query.Name))
{
filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(query.Name, "i")));
}
if (query.MinBalance.HasValue)
{
filters.Add(filterBuilder.Gte(a => a.Balance, query.MinBalance.Value));
}
if (query.MaxBalance.HasValue)
{
filters.Add(filterBuilder.Lte(a => a.Balance, query.MaxBalance.Value));
}
if (!query.IncludeInactive)
{
filters.Add(filterBuilder.Gt(a => a.Balance, 0));
}
// Additional complex filtering logic...
var combinedFilter = filters.Any()
? filterBuilder.And(filters)
: filterBuilder.Empty;
return _collection.Find(combinedFilter).ToList();
}
Observable Query Arguments
Observable queries can also accept arguments:
[HttpGet("owner/{ownerId}/observable")]
public ISubject<IEnumerable<DebitAccount>> GetAccountsByOwnerObservable(CustomerId ownerId)
{
return _collection.Observe(account => account.Owner == ownerId);
}
[HttpGet("filtered-observable")]
public ISubject<IEnumerable<DebitAccount>> GetFilteredAccountsObservable(
[FromQuery] decimal? minBalance = null)
{
if (minBalance.HasValue)
{
return _collection.Observe(account => account.Balance >= minBalance.Value);
}
return _collection.Observe();
}
Argument Types
Arc supports various argument types:
Primitive Types
[HttpGet("by-balance")]
public IEnumerable<DebitAccount> GetAccountsByBalance(
[FromQuery] decimal balance,
[FromQuery] bool exactMatch = false)
{
return exactMatch
? _collection.Find(a => a.Balance == balance).ToList()
: _collection.Find(a => a.Balance >= balance).ToList();
}
Concept Types
Using concept types (value objects) for stronger typing:
[HttpGet("by-owner-concept/{ownerId}")]
public IEnumerable<DebitAccount> GetAccountsByOwnerConcept(CustomerId ownerId)
{
return _collection.Find(a => a.Owner == ownerId).ToList();
}
Collection Arguments
[HttpGet("by-ids")]
public IEnumerable<DebitAccount> GetAccountsByIds([FromQuery] AccountId[] ids)
{
return _collection.Find(a => ids.Contains(a.Id)).ToList();
}
[HttpGet("by-owners")]
public IEnumerable<DebitAccount> GetAccountsByOwners([FromQuery] List<CustomerId> ownerIds)
{
return _collection.Find(a => ownerIds.Contains(a.Owner)).ToList();
}
Enums
public enum AccountStatus { Active, Inactive, Suspended }
[HttpGet("by-status")]
public IEnumerable<DebitAccount> GetAccountsByStatus([FromQuery] AccountStatus status)
{
// Implement status filtering logic
return status switch
{
AccountStatus.Active => _collection.Find(a => a.Balance > 0).ToList(),
AccountStatus.Inactive => _collection.Find(a => a.Balance == 0).ToList(),
AccountStatus.Suspended => _collection.Find(a => a.Balance < 0).ToList(),
_ => _collection.Find(_ => false).ToList()
};
}
Nullable Arguments
Optional arguments should be nullable:
[HttpGet("flexible-search")]
public IEnumerable<DebitAccount> FlexibleSearch(
[FromQuery] string? name = null,
[FromQuery] CustomerId? ownerId = null,
[FromQuery] decimal? minBalance = null,
[FromQuery] decimal? maxBalance = null)
{
var filterBuilder = Builders<DebitAccount>.Filter;
var filters = new List<FilterDefinition<DebitAccount>>();
if (!string.IsNullOrEmpty(name))
filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(name, "i")));
if (ownerId.HasValue)
filters.Add(filterBuilder.Eq(a => a.Owner, ownerId.Value));
if (minBalance.HasValue)
filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));
if (maxBalance.HasValue)
filters.Add(filterBuilder.Lte(a => a.Balance, maxBalance.Value));
var combinedFilter = filters.Any()
? filterBuilder.And(filters)
: filterBuilder.Empty;
return _collection.Find(combinedFilter).ToList();
}
Default Values
Provide sensible default values for optional parameters:
[HttpGet("paged")]
public IEnumerable<DebitAccount> GetPagedAccounts(
[FromQuery] int page = 0,
[FromQuery] int pageSize = 50,
[FromQuery] string sortBy = "name",
[FromQuery] bool ascending = true)
{
var query = _collection.Find(_ => true);
// Apply sorting
query = ascending
? query.SortBy(sortBy)
: query.SortByDescending(sortBy);
// Apply paging
return query.Skip(page * pageSize).Limit(pageSize).ToList();
}
Request Body Arguments
For complex input that doesn't fit well in URLs, use request body parameters:
public record ComplexSearchCriteria(
string[] SearchTerms,
Dictionary<string, object> CustomFilters,
DateRange DateRange,
SortOptions[] SortBy);
[HttpPost("complex-search")]
public async Task<IEnumerable<DebitAccount>> ComplexSearch([FromBody] ComplexSearchCriteria criteria)
{
var filterBuilder = Builders<DebitAccount>.Filter;
var filters = new List<FilterDefinition<DebitAccount>>();
// Build filters from complex criteria
foreach (var term in criteria.SearchTerms)
{
filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(term, "i")));
}
// Apply custom filters, date ranges, etc.
var combinedFilter = filters.Any()
? filterBuilder.And(filters)
: filterBuilder.Empty;
var result = await _collection.FindAsync(combinedFilter);
return result.ToList();
}
Model Binding Attributes
Use model binding attributes to control how arguments are bound:
[HttpGet("mixed-binding/{id}")]
public DebitAccount GetAccountMixed(
[FromRoute] AccountId id,
[FromQuery] bool includeDetails = false,
[FromHeader] string acceptLanguage = "en-US")
{
var account = _collection.Find(a => a.Id == id).FirstOrDefault();
if (includeDetails && account is not null)
{
// Add additional details based on language preference
// Implementation details...
}
return account;
}
Validation
Add validation attributes to ensure argument quality:
[HttpGet("validated-search")]
public IEnumerable<DebitAccount> ValidatedSearch(
[FromQuery] [Required] [MinLength(3)] string searchTerm,
[FromQuery] [Range(1, 100)] int pageSize = 20,
[FromQuery] [Range(0, int.MaxValue)] int page = 0)
{
// Validation is automatically applied
var filter = Builders<DebitAccount>.Filter.Regex(
a => a.Name,
new BsonRegularExpression(searchTerm, "i"));
return _collection.Find(filter)
.Skip(page * pageSize)
.Limit(pageSize)
.ToList();
}
Best Practices
- Use route parameters for identifiers - Things that identify specific resources
- Use query strings for filters - Optional parameters that modify results
- Use request body for complex data - When you need to send structured data
- Provide default values - Make optional parameters truly optional
- Use nullable types - For optional parameters that might not be provided
- Validate input - Use validation attributes to ensure data quality
- Use concepts over primitives - Leverage value objects for stronger typing
- Keep URLs readable - Don't overload URLs with too many parameters
Note: The proxy generator automatically creates TypeScript types for your query arguments, making them strongly typed on the frontend as well.