, CancellationToken) method. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
- + "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
+ + "Please report this issue at https://github.com/intility/json-api-toolkit/issues"
);
}
}
@@ -297,7 +297,7 @@ Type newPropertyType
throw new InvalidOperationException(
"Could not find EntityFrameworkQueryableExtensions.ThenInclude method. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
- + "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
+ + "Please report this issue at https://github.com/intility/json-api-toolkit/issues"
);
foreach (var candidate in candidates)
@@ -322,7 +322,7 @@ Type newPropertyType
?? throw new InvalidOperationException(
"Could not find EntityFrameworkQueryableExtensions.ThenInclude method. "
+ "Ensure Microsoft.EntityFrameworkCore is properly referenced. "
- + "Please report this issue at https://github.com/Intility/Intility.JsonApiToolkit/issues"
+ + "Please report this issue at https://github.com/intility/json-api-toolkit/issues"
);
s_thenIncludeReference ??= s_thenIncludeCollection;
}
diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj
index f142772..79005af 100644
--- a/JsonApiToolkit/JsonApiToolkit.csproj
+++ b/JsonApiToolkit/JsonApiToolkit.csproj
@@ -11,7 +11,7 @@
Intility
A toolkit for implementing JSON:API specification in .NET applications
jsonapi;api;rest;dotnet
- https://github.com/intility/Intility.JsonApiToolkit
+ https://github.com/intility/json-api-toolkit
git
README.md
MIT
diff --git a/JsonApiToolkit/packages.lock.json b/JsonApiToolkit/packages.lock.json
index 2d7c1e4..68df6a2 100644
--- a/JsonApiToolkit/packages.lock.json
+++ b/JsonApiToolkit/packages.lock.json
@@ -413,6 +413,18 @@
"resolved": "4.7.0",
"contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ=="
}
+ },
+ "net10.0/linux-musl-x64": {
+ "System.Security.Cryptography.Pkcs": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "UPWqLSygJlFerRi9XNIuM0a1VC8gHUIufyP24xQ0sc+XimqUAEcjpOz9DhKpyDjH+5B/wO3RpC0KpkEeDj/ddg=="
+ },
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "4.7.0",
+ "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/README.md b/README.md
index f4c4f0d..6ab503a 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,81 @@
-[](https://github.com/intility/Intility.JsonApiToolkit/actions/workflows/ci-cd.yml)
-[](https://github.com/intility/Intility.JsonApiToolkit/actions/workflows/docs.yml)
-
-# Intility.JsonApiToolkit
-
-JsonApiToolkit is a lightweight toolkit for implementing the [JSON:API specification](https://jsonapi.org/) in .NET applications.
+
+ 
+ JsonApiToolkit
+
+
+
+ Build JSON:API endpoints in ASP.NET Core.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Description
+
+JsonApiToolkit translates [JSON:API](https://jsonapi.org/) query parameters (`filter[]`, `sort`, `include`, `fields[]`, `page[]`) into typed EF Core queries and shapes responses as spec-compliant documents, so your ASP.NET Core controllers stay short.
## Installation
-To install this package from Intility's GitHub Packages, add this to your NuGet.config file:
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-Then install the package via NuGet:
-
```bash
dotnet add package Intility.JsonApiToolkit
```
-## Setup
+## Usage
-1. **Register services:**
- In your `Program.cs` or `Startup.cs`, add the toolkit services to your dependency injection container:
+Register the toolkit in `Program.cs`:
- ```csharp
- builder.Services.AddJsonApiToolkit();
- ```
-
-2. **Inheritance:**
- Derive your API controllers from the provided `JsonApiController` to leverage helper methods that return JSON:API compliant responses.
+```csharp
+builder.Services.AddJsonApiToolkit();
+```
- ```csharp
- public class BooksController : JsonApiController
- {
- // Your endpoint implementations here
- }
- ```
+Derive controllers from `JsonApiController` and let `JsonApiQueryAsync` handle the request:
-3. **Configuration:**
- The toolkit automatically configures JSON serialization settings (camelCase properties, ignoring nulls, etc.) and adds the JSON:API media type to the supported output formatters.
+```csharp
+public class BooksController : JsonApiController
+{
+ private const string ResourceType = "book";
+
+ [HttpGet]
+ [AllowedIncludes("author", "publisher")]
+ public async Task GetAllAsync()
+ {
+ return await JsonApiQueryAsync(_dbContext.Books, ResourceType);
+ }
+}
+```
-## GitHub Actions
-To get fetch the package in your GitHub Actions workflow, add the following to your workflow file:
+Then call the endpoint with JSON:API query parameters:
-```yaml
-- name: Add Intility NuGet Package Source
- run: |
- dotnet nuget add source https://nuget.pkg.github.com/Intility/index.json \
- --name Intility \
- --username ${{ github.actor }} \
- --password ${{ secrets.GITHUB_TOKEN }} \
- --store-password-in-clear-text
+```
+GET /api/books?filter[title]=javascript&include=author&fields[book]=title,published&page[size]=10&sort=-published
```
-> [!IMPORTANT]
-> Your Github token must have the `read:packages` scope to access the package.
-
-## Endpoint Example
+## What it provides
-```csharp
-// GET: api/books
-[HttpGet]
-public async Task GetBooks()
-{
- var query = _dbContext.Books.AsQueryable();
+- **JSON:API documents** - compliant `data` / `included` / `meta` / `links` / `errors` envelope on every response
+- **Filtering** - `filter[field]=value` with operators, nested paths, and filtering on included resources
+- **Sorting** - `sort=field,-other` with multi-field and descending support
+- **Pagination** - `page[number]` / `page[size]` with link generation, total counts, and clamping
+- **Sparse fieldsets** - `fields[type]=a,b` to limit returned attributes per resource type
+- **Included resources** - `include=author,publisher.country` with allowlisting via `[AllowedIncludes]`
+- **EF Core integration** - query operators translate directly to SQL via `IQueryable`
+- **Strict mode** - return 404 for out-of-range pages and validate filter paths against allowed includes
- // JsonApiQueryAsync applies filtering, sorting, includes, and pagination automatically.
- return await JsonApiQueryAsync(query, "book");
-}
-```
+## Documentation
-[More examples (WIP)](https://congenial-telegram-prep6vq.pages.github.io/docs/api-controller-examples.html)
+Full documentation is at .
-## Documentation
-For complete documentation and detailed usage instructions, please visit our
-[documentation page.](https://intility.github.io/Intility.JsonApiToolkit/)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..0f70575
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability in JsonApiToolkit, please report it privately. **Do not open a public issue.**
+
+Use [GitHub private vulnerability reporting](https://github.com/intility/json-api-toolkit/security/advisories/new). If that's unavailable to you, email .
+
+Please include:
+
+- A description of the vulnerability and its potential impact
+- Steps to reproduce or a PoC
+- The affected version(s) of JsonApiToolkit
+- Any suggested mitigations
diff --git a/docs/.pages b/docs/.pages
index 33f3712..c925f10 100644
--- a/docs/.pages
+++ b/docs/.pages
@@ -1,18 +1,16 @@
nav:
- Home:
- Welcome: index.md
- - introduction.md
- getting-started.md
- Guides:
- querying.md
- build-query.md
- - enhanced-error-handling.md
- - performance.md
+ - error-handling.md
- security.md
- - api-controller-examples.md
- - debugging.md
- - upgrade-guide.md
- - integrations
+ - performance.md
+ - recipes.md
+ - troubleshooting.md
+ - openapi.md
- api
- contributing.md
- changelog.md
diff --git a/docs/api-controller-examples.md b/docs/api-controller-examples.md
deleted file mode 100644
index 5b397c6..0000000
--- a/docs/api-controller-examples.md
+++ /dev/null
@@ -1,249 +0,0 @@
-# API Controller Examples
-
-This guide provides practical examples of implementing JSON:API controllers using JsonApiToolkit.
-
-## Basic Controller Setup
-
-```csharp
-using JsonApiToolkit.Controllers;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-[ApiController]
-[Route("api/[controller]")]
-public class BooksController : JsonApiController
-{
- private readonly AppDbContext _context;
-
- public BooksController(AppDbContext context)
- {
- _context = context;
- }
-
- // Controller methods here...
-}
-```
-
-## GET Collection with Full Query Support
-
-```csharp
-[HttpGet]
-public async Task GetBooks()
-{
- IQueryable books = _context.Books.AsQueryable();
- return await JsonApiQueryAsync(books, "book");
-}
-```
-
-This automatically supports:
-- Filtering: `GET /api/books?filter[title][like]=Hobbit`
-- Sorting: `GET /api/books?sort=-publishedDate,title`
-- Pagination: `GET /api/books?page[number]=2&page[size]=10`
-- Includes: `GET /api/books?include=author,reviews`
-
-## GET Single Resource
-
-```csharp
-[HttpGet("{id}")]
-public async Task GetBook(int id)
-{
- var book = await _context.Books
- .FirstOrDefaultAsync(b => b.Id == id);
-
- if (book == null)
- return JsonApiNotFound($"Book with ID {id} not found");
-
- return JsonApiOk(book, "book");
-}
-```
-
-## POST Create Resource
-
-```csharp
-[HttpPost]
-public async Task CreateBook([FromBody] CreateBookRequest request)
-{
- if (string.IsNullOrWhiteSpace(request.Title))
- throw new JsonApiBadRequestException("Book title cannot be empty");
-
- var book = new Book
- {
- Title = request.Title,
- Author = request.Author,
- PublishedDate = request.PublishedDate
- };
-
- _context.Books.Add(book);
- await _context.SaveChangesAsync();
-
- return JsonApiCreated(book, "book", book.Id.ToString());
-}
-```
-
-## PUT Update Resource
-
-```csharp
-[HttpPut("{id}")]
-public async Task UpdateBook(int id, [FromBody] UpdateBookRequest request)
-{
- var book = await _context.Books.FindAsync(id);
- if (book == null)
- throw new JsonApiNotFoundException($"Book with ID {id} not found");
-
- book.Title = request.Title ?? book.Title;
- book.Author = request.Author ?? book.Author;
- book.PublishedDate = request.PublishedDate ?? book.PublishedDate;
-
- await _context.SaveChangesAsync();
-
- return JsonApiOk(book, "book");
-}
-```
-
-## DELETE Resource
-
-```csharp
-[HttpDelete("{id}")]
-public async Task DeleteBook(int id)
-{
- var book = await _context.Books.FindAsync(id);
- if (book == null)
- throw new JsonApiNotFoundException($"Book with ID {id} not found");
-
- _context.Books.Remove(book);
- await _context.SaveChangesAsync();
-
- return JsonApiNoContent();
-}
-```
-
-## Advanced Filtering Example
-
-```csharp
-[HttpGet("search")]
-public async Task SearchBooks()
-{
- // The filtering is handled automatically by JsonApiQueryAsync
- IQueryable books = _context.Books.AsQueryable();
-
- // But you can also apply custom logic before the standard processing
- books = books.Where(b => b.IsPublished); // Custom business logic
-
- return await JsonApiQueryAsync(books, "book");
-}
-```
-
-**Supports complex queries like:**
-```
-GET /api/books/search?filter[and][0][price][gt]=10&filter[and][1][or][0][genre]=fiction&filter[and][1][or][1][genre]=fantasy&sort=-rating,title&include=author
-```
-
-## Custom Response with Manual Mapping
-
-```csharp
-[HttpGet("{id}/summary")]
-public IActionResult GetBookSummary(int id)
-{
- var book = _context.Books
- .Include(b => b.Author)
- .FirstOrDefault(b => b.Id == id);
-
- if (book == null)
- return JsonApiNotFound();
-
- // Manual mapping for custom response structure
- var summary = new BookSummary
- {
- Id = book.Id,
- Title = book.Title,
- AuthorName = book.Author?.Name,
- PageCount = book.Pages
- };
-
- return JsonApiOk(summary, "bookSummary");
-}
-```
-
-## Error Handling Examples
-
-```csharp
-[HttpPost("{id}/reserve")]
-public async Task ReserveBook(int id)
-{
- var book = await _context.Books.FindAsync(id);
- if (book == null)
- throw new JsonApiNotFoundException($"Book with ID {id} not found");
-
- if (book.IsReserved)
- throw new JsonApiConflictException($"Book '{book.Title}' is already reserved");
-
- if (!User.Identity?.IsAuthenticated == true)
- throw new JsonApiUnauthorizedException("You must be logged in to reserve books");
-
- // Business logic here...
-
- return JsonApiOk(book, "book");
-}
-```
-
-## Entity Configuration
-
-For the examples above, here's the entity setup:
-
-```csharp
-public class Book
-{
- public int Id { get; set; }
- public string Title { get; set; } = string.Empty;
- public string Author { get; set; } = string.Empty;
- public DateTime PublishedDate { get; set; }
- public decimal Price { get; set; }
- public bool IsPublished { get; set; }
- public bool IsReserved { get; set; }
-
- // Navigation properties for relationships
- public List Reviews { get; set; } = [];
- public Author AuthorDetails { get; set; } = null!;
-}
-```
-
-## Security with AllowedIncludes
-
-Control which relationships can be included to prevent exposure of sensitive data:
-
-```csharp
-[HttpGet("users")]
-[AllowedIncludes("profile", "posts.*", "settings")]
-public async Task GetUsers()
-{
- return await JsonApiQueryAsync(_context.Users, "user");
-}
-
-[HttpGet("sensitive-data")]
-[AllowedIncludes("publicInfo")]
-public async Task GetSensitiveData()
-{
- return await JsonApiQueryAsync(_context.SensitiveEntities, "sensitiveEntity");
-}
-
-[HttpGet("public-only")]
-[AllowedIncludes()] // No includes allowed
-public async Task GetPublicOnly()
-{
- return await JsonApiQueryAsync(_context.PublicData, "publicData");
-}
-```
-
-**Supported requests:**
-- `GET /api/users?include=profile` ✅ Allowed
-- `GET /api/users?include=posts.comments` ✅ Allowed (wildcard)
-- `GET /api/users?include=posts.comments.author` ❌ Forbidden (too deep)
-- `GET /api/sensitive-data?include=secrets` ❌ Forbidden
-
-## Pro Tips
-
-1. **Always use exception types** instead of returning error ActionResults - the filter handles conversion automatically
-2. **Use JsonApiQueryAsync for collections** when you want automatic query processing (filtering, sorting, pagination)
-3. **Use JsonApiOk for already-loaded data** when you've fetched and processed entities yourself
-4. **Use AllowedIncludes** - restrict relationship access for security and performance
-
diff --git a/docs/build-query.md b/docs/build-query.md
index 2a6e88c..66e2c73 100644
--- a/docs/build-query.md
+++ b/docs/build-query.md
@@ -1,312 +1,123 @@
-# Building Custom Queries
-
-The `BuildJsonApiQueryAsync` method provides access to the processed query **before execution**, enabling custom operations like CSV exports, aggregations, and projections.
-
-## Overview
-
-```csharp
-protected async Task> BuildJsonApiQueryAsync(
- IQueryable queryable,
- string resourceType,
- bool includeCount = true)
- where T : class
-```
-
-**Returns:**
-
-| Property | Type | Description |
-|----------|------|-------------|
-| `Query` | `IQueryable` | The processed query with filters, includes, and sorting applied. **Pagination is NOT applied.** |
-| `Parameters` | `QueryParameters` | The parsed query parameters from the request. |
-| `TotalCount` | `int` | Total matching records. Returns 0 if `includeCount` is false. |
-
-## When to Use
-
-Use `BuildJsonApiQueryAsync` when you need to:
-
-- **Export data** (CSV, Excel, JSON file) - you need all matching records, not paginated results
-- **Aggregate data** - apply GROUP BY after filtering
-- **Custom projections** - select specific columns or transform the data
-- **Stream results** - process large datasets without loading everything into memory
-- **Combine with other queries** - use the filtered query as a subquery
-
-For standard JSON:API responses with pagination, use `JsonApiQueryAsync` instead.
-
-## Examples
-
-### CSV Export
-
-```csharp
-[HttpGet("export")]
-public async Task ExportBooks()
-{
- var result = await BuildJsonApiQueryAsync(_context.Books, "books");
- var books = await result.Query.ToListAsync();
-
- var csv = new StringBuilder();
- csv.AppendLine("Id,Title,Author,PublishedDate");
-
- foreach (var book in books)
- {
- csv.AppendLine($"{book.Id},{CsvSafe(book.Title)},{CsvSafe(book.Author)},{book.PublishedDate:yyyy-MM-dd}");
- }
-
- return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", "books.csv");
-}
-
-// Prevent CSV injection - Excel treats =, +, -, @ as formula prefixes
-private static string CsvSafe(string? value)
-{
- if (string.IsNullOrEmpty(value)) return "";
- if (value.StartsWith('=') || value.StartsWith('+') || value.StartsWith('-') || value.StartsWith('@'))
- return "'" + value;
- return value.Contains(',') ? $"\"{value}\"" : value;
-}
-```
-
-**Request:**
-```
-GET /api/books/export?filter[publishedDate][gt]=2020-01-01&sort=title&include=author
-```
-
-All matching books are exported (no pagination), with filters, sorting, and includes applied.
-
-### Custom Projection
-
-```csharp
-[HttpGet("titles-only")]
-public async Task GetTitlesOnly()
-{
- var result = await BuildJsonApiQueryAsync(_context.Books, "books");
-
- // Project to minimal DTO
- var titles = await result.Query
- .Select(b => new { b.Id, b.Title })
- .ToListAsync();
-
- return Ok(new
- {
- count = result.TotalCount,
- titles
- });
-}
-```
-
-### Streaming Large Datasets
-
-```csharp
-[HttpGet("stream")]
-public async IAsyncEnumerable StreamBooks()
-{
- var result = await BuildJsonApiQueryAsync(_context.Books, "books", includeCount: false);
-
- await foreach (var book in result.Query.AsAsyncEnumerable())
- {
- yield return new BookDto
- {
- Id = book.Id,
- Title = book.Title,
- Author = book.Author?.Name
- };
- }
-}
-```
-
-### Combining with Other Queries
-
-```csharp
-[HttpGet("with-sales")]
-public async Task GetBooksWithSales()
-{
- var result = await BuildJsonApiQueryAsync(_context.Books, "books");
-
- // Join with sales data from another source
- var booksWithSales = await result.Query
- .Join(
- _context.Sales,
- book => book.Id,
- sale => sale.BookId,
- (book, sale) => new { book, sale })
- .GroupBy(x => x.book)
- .Select(g => new
- {
- Book = g.Key.Title,
- TotalSales = g.Sum(x => x.sale.Quantity),
- Revenue = g.Sum(x => x.sale.Amount)
- })
- .ToListAsync();
-
- return Ok(booksWithSales);
-}
-```
-
----
-
-## Statistics and Aggregations
-
-When building statistics endpoints, you need to apply filters **before** aggregating. Otherwise, filters won't work on your DTOs.
-
-### The Problem
-
-```csharp
-[HttpGet("genre-stats")]
-public async Task GetGenreStatsAsync()
-{
- var stats = _context.Books
- .GroupBy(b => b.Genre)
- .Select(g => new { Genre = g.Key, Count = g.Count() });
-
- // ❌ This FAILS - the DTO doesn't have PublishedDate
- return await JsonApiQueryAsync(stats, "genre_stats");
-}
-```
-
-**Request:** `GET /api/books/genre-stats?filter[publishedDate][gt]=2020-01-01`
-
-The filter tries to apply to the anonymous DTO, but it doesn't have `PublishedDate`. The filter is silently skipped.
-
-### Solution: Filter First, Then Aggregate
-
-Use `BuildJsonApiQueryAsync` to apply filters to the source entity, then aggregate:
-
-```csharp
-[HttpGet("genre-stats")]
-public async Task GetGenreStatsAsync()
-{
- // Apply filters to Book entity BEFORE aggregation
- var result = await BuildJsonApiQueryAsync(_context.Books, "books", includeCount: false);
-
- // Aggregate the already-filtered query
- var stats = await result.Query
- .GroupBy(b => b.Genre)
- .Select(g => new
- {
- Genre = g.Key,
- Count = g.Count(),
- AveragePrice = g.Average(b => b.Price)
- })
- .ToListAsync();
-
- return Ok(stats);
-}
-```
-
-Now `filter[publishedDate][gt]=2020-01-01` correctly filters books before counting.
-
-### Simple Aggregations with ApplyFiltersOnly
-
-For simple aggregations where you don't need includes or sorting, use `ApplyFiltersOnly()`:
-
-```csharp
-[HttpGet("genre-stats")]
-public async Task GetGenreStatsAsync()
-{
- // Only apply filters (no includes, no sorting)
- var query = ApplyFiltersOnly(_context.Books);
-
- var stats = await query
- .GroupBy(b => b.Genre)
- .Select(g => new { Genre = g.Key, Count = g.Count() })
- .ToListAsync();
-
- return Ok(stats);
-}
-```
-
-### With Business Logic Filters
-
-Combine user filters with required business logic:
-
-```csharp
-[HttpGet("publisher-stats")]
-public async Task GetPublisherStatsAsync()
-{
- // Start with business logic filter
- var query = _context.Books.Where(b => b.Status == BookStatus.Published);
-
- // Apply user filters from query params
- query = ApplyFiltersOnly(query);
-
- // Aggregate
- var stats = await query
- .GroupBy(b => b.Publisher)
- .Select(g => new
- {
- Publisher = g.Key,
- TotalBooks = g.Count(),
- AveragePrice = g.Average(b => b.Price)
- })
- .ToListAsync();
-
- return Ok(stats);
-}
-```
-
-### Why Return Plain JSON for Statistics?
-
-Statistics and aggregations aren't "resources" with stable IDs or relationships - they're computed views. Return plain JSON instead of JSON:API documents.
-
----
-
-## Performance Considerations
-
-### Skip Count When Not Needed
-
-If you don't need the total count, set `includeCount: false` to skip the COUNT query:
-
-```csharp
-// Skips COUNT query - better for large datasets
-var result = await BuildJsonApiQueryAsync(query, "books", includeCount: false);
-```
-
-### Use AsNoTracking for Read-Only Operations
-
-For export/read-only operations, use `AsNoTracking()` on your queryable:
-
-```csharp
-var result = await BuildJsonApiQueryAsync(
- _context.Books.AsNoTracking(),
- "books"
-);
-```
-
-### Consider Streaming for Large Exports
-
-For very large datasets, use streaming instead of `ToListAsync()`:
-
-```csharp
-await foreach (var item in result.Query.AsAsyncEnumerable())
-{
- // Process one item at a time
-}
-```
-
----
-
-## Method Comparison
-
-| Method | Filters | Includes | Sorting | Pagination | Returns |
-|--------|---------|----------|---------|------------|---------|
-| `JsonApiQueryAsync` | Yes | Yes | Yes | Yes | JSON:API document |
-| `BuildJsonApiQueryAsync` | Yes | Yes | Yes | **No** | `JsonApiQueryResult` |
-| `ApplyFiltersOnly` | Yes | No | No | No | `IQueryable` |
-
-**When to use each:**
-
-- **`JsonApiQueryAsync`** - Standard API responses with pagination
-- **`BuildJsonApiQueryAsync`** - Exports, projections, when you need includes/sorting
-- **`ApplyFiltersOnly`** - Simple aggregations where you only need filtering
-
-## Query Parameters Applied
-
-`BuildJsonApiQueryAsync` applies the following query parameters:
-
-| Parameter | Applied |
-|-----------|---------|
-| `filter` | Yes - filters main entity |
-| `filter[relation.field]` | Yes - filtered includes |
-| `include` | Yes - eager loads relationships |
-| `sort` | Yes - orders results |
-| `page[number]`, `page[size]` | Parsed but **NOT applied** |
-
-The pagination parameters are still available in `result.Parameters.Pagination` if you need them for custom pagination logic.
+# Building Custom Queries
+
+`BuildJsonApiQueryAsync` exposes the processed query *before execution*, so you can apply your own logic on top: exports, projections, streaming, aggregations.
+
+For standard JSON:API responses, use `JsonApiQueryAsync` instead.
+
+## Signature
+
+```csharp
+protected Task> BuildJsonApiQueryAsync(
+ IQueryable queryable,
+ string resourceType,
+ bool includeCount = true) where T : class
+```
+
+`JsonApiQueryResult` exposes:
+
+- `Query`: the `IQueryable` with filters, includes, and sorting applied. **Pagination is not applied.**
+- `Parameters`: the parsed `QueryParameters`, including pagination if you want to apply it manually.
+- `TotalCount`: total matching records, or `0` if `includeCount: false`.
+
+## Custom queries: export example
+
+The pattern is the same for CSV, Excel, JSON file, projection, or streaming: take `result.Query`, do whatever you want with it.
+
+```csharp
+[HttpGet("export")]
+public async Task ExportBooks()
+{
+ var result = await BuildJsonApiQueryAsync(_db.Books.AsNoTracking(), ResourceType);
+
+ var books = await result.Query.ToListAsync();
+ // serialize however you want: CSV, Excel, NDJSON, etc.
+ return File(BuildCsv(books), "text/csv", "books.csv");
+}
+```
+
+For very large exports, stream instead of `ToListAsync()`:
+
+```csharp
+await foreach (var book in result.Query.AsAsyncEnumerable())
+{
+ // one entity at a time
+}
+```
+
+For projections, chain `Select` after `result.Query`:
+
+```csharp
+var titles = await result.Query.Select(b => new { b.Id, b.Title }).ToListAsync();
+```
+
+Pass `includeCount: false` to skip the COUNT query when you don't need it.
+
+---
+
+## Statistics and aggregations
+
+Aggregation endpoints have a subtle trap: if you `GroupBy().Select(...)` first and hand the resulting query to `JsonApiQueryAsync`, user filters target the projected DTO, not the source entity. Any filter referencing a column the DTO doesn't expose is silently skipped, so requests look like they succeed but ignore the filter.
+
+```csharp
+// Filter is silently skipped: the anonymous DTO has no PublishedDate property.
+var stats = _db.Books
+ .GroupBy(b => b.Genre)
+ .Select(g => new { Genre = g.Key, Count = g.Count() });
+
+return await JsonApiQueryAsync(stats, ResourceType);
+```
+
+Apply filters to the source entity *first*, then aggregate:
+
+```csharp
+[HttpGet("genre-stats")]
+public async Task GetGenreStats()
+{
+ var result = await BuildJsonApiQueryAsync(_db.Books, ResourceType, includeCount: false);
+
+ var stats = await result.Query
+ .GroupBy(b => b.Genre)
+ .Select(g => new
+ {
+ Genre = g.Key,
+ Count = g.Count(),
+ AveragePrice = g.Average(b => b.Price)
+ })
+ .ToListAsync();
+
+ return Ok(stats);
+}
+```
+
+Now `?filter[publishedDate][gt]=2020-01-01` filters books before the GROUP BY.
+
+### `ApplyFiltersOnly` for simple cases
+
+If you don't need includes or sorting, `ApplyFiltersOnly` is shorter:
+
+```csharp
+var query = ApplyFiltersOnly(_db.Books);
+
+var stats = await query
+ .GroupBy(b => b.Genre)
+ .Select(g => new { Genre = g.Key, Count = g.Count() })
+ .ToListAsync();
+```
+
+You can compose it with your own `Where` clauses:
+
+```csharp
+// business logic first, then user filters
+var query = _db.Books.Where(b => b.Status == BookStatus.Published);
+query = ApplyFiltersOnly(query);
+```
+
+Statistics aren't really "resources" with stable IDs and relationships, so return plain `Ok(...)` rather than a JSON:API document.
+
+## Method cheat sheet
+
+| Method | Filters | Includes | Sorting | Pagination | Returns |
+|--------|---------|----------|---------|------------|---------|
+| `JsonApiQueryAsync` | yes | yes | yes | yes | JSON:API document |
+| `BuildJsonApiQueryAsync` | yes | yes | yes | no | `JsonApiQueryResult` |
+| `ApplyFiltersOnly` | yes | no | no | no | `IQueryable` |
diff --git a/docs/contributing.md b/docs/contributing.md
index a2dedc2..89c22f3 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -63,9 +63,9 @@ Branch names: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/`, etc.
1. Branch from `main`.
2. Format (`dotnet csharpier format .`) and run tests locally.
-3. Open a PR with a descriptive title and bullet summary.
-4. CI must pass `build-and-test` and `Docs: Build` (required status checks).
-5. Squash merge (the only merge method allowed on `main`).
+3. Open a PR with a descriptive title and summary.
+4. CI must pass.
+5. At least one approving review is required.
## Releases
@@ -73,4 +73,4 @@ Handled by [Release Please](https://github.com/googleapis/release-please). Mergi
## Questions
-Open an issue:
+Open an issue:
diff --git a/docs/debugging.md b/docs/debugging.md
deleted file mode 100644
index bc44991..0000000
--- a/docs/debugging.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# Debugging Guide
-
-## Enable Logging
-
-```json
-{
- "Serilog": {
- "MinimumLevel": {
- "Override": {
- "JsonApiToolkit": "Debug",
- "Microsoft.EntityFrameworkCore.Database.Command": "Information"
- }
- }
- }
-}
-```
-
-Or for Microsoft.Extensions.Logging:
-
-```json
-{
- "Logging": {
- "LogLevel": {
- "JsonApiToolkit": "Debug",
- "Microsoft.EntityFrameworkCore.Database.Command": "Information"
- }
- }
-}
-```
-
-## What Gets Logged
-
-**Information:**
-- Empty result explanations
-- Complex queries (>20 filters)
-
-**Warning:**
-- Invalid property/field names
-- Parameter parsing issues
-- Large unpaginated results (>1000)
-- Include mapping problems
-
-**Debug:**
-- Query summaries (filters/sorts/includes/pagination)
-- Include strategy (SingleQuery/SplitQuery)
-- Execution summaries (counts)
-
-**EF Core SQL (Information level):**
-- Actual SQL queries executed
diff --git a/docs/enhanced-error-handling.md b/docs/enhanced-error-handling.md
deleted file mode 100644
index 6f5afb7..0000000
--- a/docs/enhanced-error-handling.md
+++ /dev/null
@@ -1,227 +0,0 @@
-# Enhanced Error Handling in JsonApiToolkit
-
-JsonApiToolkit provides a clean, consistent way to handle errors in your ASP.NET Core APIs. By throwing specific exceptions in your services or controllers, you get:
-
-- **Standardized JSON:API error responses** for your clients
-- **Clear, minimal logging**: only unexpected errors include stack traces
-
-## Supported Exceptions
-
-Throw these exceptions in your code to trigger the corresponding HTTP status and error response:
-
-| Exception Type | HTTP Status | Typical Use Case |
-|-----------------------------------|-------------|---------------------------------------|
-| `JsonApiBadRequestException` | 400 | Validation or malformed input |
-| `JsonApiUnauthorizedException` | 401 | Not authenticated |
-| `JsonApiForbiddenException` | 403 | Not authorized |
-| `JsonApiNotFoundException` | 404 | Resource not found |
-| `JsonApiConflictException` | 409 | Unique constraint or conflict |
-| `JsonApiTooManyRequestsException` | 429 | Rate limiting exceeded |
-
-Any other unhandled exception will result in a 500 Internal Server Error.
-
-> [!TIP]
-> If you are missing an exception type for your use case, please create an issue on GitHub.
-
-## How It Works
-
-- Throw a specific exception (e.g., `JsonApiNotFoundException`, `JsonApiBadRequestException`) in your code when an error occurs.
-- The toolkit automatically converts this into the correct HTTP status code and a JSON:API error response.
-- Only unexpected errors (500) are logged with stack traces; handled errors (400, 404, etc.) log just the type and message.
-
-## Example Usage
-
-```csharp
-if (string.IsNullOrWhiteSpace(request.Title))
- throw new JsonApiBadRequestException("Todo title cannot be empty.");
-
-var todo = await _dbContext.Todos.FirstOrDefaultAsync(t => t.Id == todoId)
- ?? throw new JsonApiNotFoundException($"Todo with ID {todoId} not found.");
-```
-
----
-
-## Example Client Response
-
-If a todo is not found, the client receives:
-
-```json
-{
- "errors": [
- {
- "status": "404",
- "title": "Not Found",
- "detail": "Todo with ID 42 not found."
- }
- ]
-}
-```
-
----
-
-## Example Console Log
-
-For the same error, your log will show:
-
-```
-[09:07:11 INF] Handled JSON:API exception: JsonApiNotFoundException - Todo with ID 42 not found.
-```
-
-*No stack trace is logged for handled errors like 400, 404, or 409.*
-
----
-
-## Advanced Error Information
-
-All JSON:API exceptions support additional structured error information following the JSON:API specification:
-
-### Enhanced Constructor
-
-```csharp
-public JsonApiBadRequestException(
- string message,
- string? code = null,
- ErrorSource? errorSource = null,
- Dictionary? meta = null,
- Exception? innerException = null
-)
-```
-
-### Usage with Additional Error Details
-
-```csharp
-// Basic usage (existing code continues to work)
-throw new JsonApiBadRequestException("Invalid email format");
-
-// Enhanced usage with error codes and source information
-throw new JsonApiBadRequestException(
- message: "Invalid email format",
- code: "INVALID_EMAIL",
- errorSource: new ErrorSource { Pointer = "/data/attributes/email" },
- meta: new Dictionary
- {
- ["expectedFormat"] = "user@domain.com",
- ["provided"] = request.Email
- }
-);
-
-// For query parameter errors
-throw new JsonApiBadRequestException(
- message: "Invalid sort field 'invalidField'",
- code: "INVALID_SORT",
- errorSource: new ErrorSource { Parameter = "sort" }
-);
-```
-
-### Enhanced Error Response
-
-The enhanced exception produces richer error responses:
-
-```json
-{
- "errors": [
- {
- "status": "400",
- "title": "Bad Request",
- "detail": "Invalid email format",
- "code": "INVALID_EMAIL",
- "source": {
- "pointer": "/data/attributes/email"
- },
- "meta": {
- "expectedFormat": "user@domain.com",
- "provided": "invalid-email"
- }
- }
- ]
-}
-```
-
-> [!IMPORTANT]
-> When using these exceptions, ensure that the parent is not wrapped in a try-catch block that catches all exceptions. This will prevent the toolkit from handling the error correctly.
-
----
-
-## Error Factory Methods (v1.3.0+)
-
-For common error scenarios, use the `JsonApiErrors` factory class to create consistent, well-structured errors with proper codes, source information, and metadata:
-
-### Available Factory Methods
-
-| Factory | Status | Use Case |
-|---------|--------|----------|
-| `JsonApiErrors.NotFound(type, id)` | 404 | Resource not found |
-| `JsonApiErrors.RelatedNotFound(type, id, relationship, relatedId)` | 404 | Related resource not found |
-| `JsonApiErrors.InvalidFilterValue(field, value, expectedType)` | 400 | Type conversion failed |
-| `JsonApiErrors.InvalidFilterField(field, entityType)` | 400 | Field doesn't exist |
-| `JsonApiErrors.InvalidFilterOperator(op)` | 400 | Unknown filter operator |
-| `JsonApiErrors.InvalidSortField(field, entityType)` | 400 | Sort field doesn't exist |
-| `JsonApiErrors.IncludeNotAllowed(include)` | 403 | Include blocked by AllowedIncludes |
-| `JsonApiErrors.FilterNotAllowed(relationshipPath)` | 403 | Filter on disallowed relationship |
-| `JsonApiErrors.AlreadyExists(type, field, value)` | 409 | Duplicate key violation |
-| `JsonApiErrors.ValidationFailed(field, message)` | 400 | Generic validation error |
-| `JsonApiErrors.RequiredFieldMissing(field)` | 400 | Required field not provided |
-| `JsonApiErrors.QueryTooComplex(limitName, limit, actual, configKey)` | 400 | Query exceeds limits |
-
-### Usage Examples
-
-```csharp
-// Resource not found
-var book = await _db.Books.FindAsync(id)
- ?? throw JsonApiErrors.NotFound("books", id);
-
-// Invalid filter value
-if (!int.TryParse(filterValue, out _))
- throw JsonApiErrors.InvalidFilterValue("age", filterValue, typeof(int));
-
-// Duplicate resource
-if (await _db.Users.AnyAsync(u => u.Email == email))
- throw JsonApiErrors.AlreadyExists("users", "email", email);
-
-// Validation error
-if (string.IsNullOrWhiteSpace(request.Title))
- throw JsonApiErrors.RequiredFieldMissing("title");
-```
-
-### Example Response
-
-Using `JsonApiErrors.NotFound("books", 123)` produces:
-
-```json
-{
- "errors": [{
- "status": "404",
- "code": "RESOURCE_NOT_FOUND",
- "title": "Not Found",
- "detail": "Resource 'books' with id '123' not found.",
- "meta": {
- "resourceType": "books",
- "id": 123
- }
- }]
-}
-```
-
-### Standard Error Codes
-
-All factory methods use codes from `JsonApiErrorCodes`:
-
-```csharp
-public static class JsonApiErrorCodes
-{
- public const string ResourceNotFound = "RESOURCE_NOT_FOUND";
- public const string ResourceAlreadyExists = "RESOURCE_ALREADY_EXISTS";
- public const string InvalidFilterField = "INVALID_FILTER_FIELD";
- public const string InvalidFilterValue = "INVALID_FILTER_VALUE";
- public const string InvalidFilterOperator = "INVALID_FILTER_OPERATOR";
- public const string FilterNotAllowed = "FILTER_NOT_ALLOWED";
- public const string IncludeNotAllowed = "INCLUDE_NOT_ALLOWED";
- public const string InvalidSortField = "INVALID_SORT_FIELD";
- public const string QueryTooComplex = "QUERY_TOO_COMPLEX";
- public const string ValidationFailed = "VALIDATION_FAILED";
- public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
- // ... and more
-}
-```
-
-Use these codes in your client applications to handle specific error types programmatically.
diff --git a/docs/error-handling.md b/docs/error-handling.md
new file mode 100644
index 0000000..f5223f1
--- /dev/null
+++ b/docs/error-handling.md
@@ -0,0 +1,94 @@
+# Error Handling
+
+Throw `JsonApi*Exception` types in your code and the toolkit converts them into JSON:API error responses with the right HTTP status. Only unhandled exceptions get logged with stack traces; handled errors log just type and message.
+
+## Exception types
+
+| Exception | Status | Use case |
+|-----------|--------|----------|
+| `JsonApiBadRequestException` | 400 | Validation or malformed input |
+| `JsonApiUnauthorizedException` | 401 | Not authenticated |
+| `JsonApiForbiddenException` | 403 | Not authorized |
+| `JsonApiNotFoundException` | 404 | Resource not found |
+| `JsonApiConflictException` | 409 | Unique constraint or conflict |
+| `JsonApiTooManyRequestsException` | 429 | Rate limited |
+
+Anything else becomes a 500 with full stack trace in logs.
+
+> [!IMPORTANT]
+> Don't wrap controller actions in a `try/catch` that swallows everything. The toolkit's exception filter needs the exception to bubble up.
+
+## Basic usage
+
+```csharp
+if (string.IsNullOrWhiteSpace(request.Title))
+ throw new JsonApiBadRequestException("Title cannot be empty");
+
+var todo = await _db.Todos.FirstOrDefaultAsync(t => t.Id == id)
+ ?? throw new JsonApiNotFoundException($"Todo {id} not found");
+```
+
+The client gets:
+
+```json
+{
+ "errors": [
+ {
+ "status": "404",
+ "title": "Not Found",
+ "detail": "Todo 42 not found."
+ }
+ ]
+}
+```
+
+## Adding error codes, source pointers, and meta
+
+Every `JsonApi*Exception` accepts optional `code`, `errorSource`, and `meta` arguments for richer responses:
+
+```csharp
+throw new JsonApiBadRequestException(
+ message: "Invalid email format",
+ code: "INVALID_EMAIL",
+ errorSource: new ErrorSource { Pointer = "/data/attributes/email" },
+ meta: new Dictionary
+ {
+ ["expectedFormat"] = "user@domain.com",
+ ["provided"] = request.Email
+ }
+);
+```
+
+Produces:
+
+```json
+{
+ "errors": [{
+ "status": "400",
+ "title": "Bad Request",
+ "detail": "Invalid email format",
+ "code": "INVALID_EMAIL",
+ "source": { "pointer": "/data/attributes/email" },
+ "meta": { "expectedFormat": "user@domain.com", "provided": "..." }
+ }]
+}
+```
+
+For query-parameter errors, set `errorSource: new ErrorSource { Parameter = "sort" }` instead of a pointer.
+
+## Factory methods
+
+For common errors, the `JsonApiErrors` factory produces consistent codes, sources, and meta without spelling them out each time:
+
+```csharp
+var book = await _db.Books.FindAsync(id)
+ ?? throw JsonApiErrors.NotFound(ResourceType, id);
+
+if (await _db.Users.AnyAsync(u => u.Email == email))
+ throw JsonApiErrors.AlreadyExists("users", "email", email);
+
+if (string.IsNullOrWhiteSpace(request.Title))
+ throw JsonApiErrors.RequiredFieldMissing("title");
+```
+
+The full list of factory methods (and the standard error codes they emit) is in the [API reference for `JsonApiErrors`](api/JsonApiToolkit.Models.Errors/JsonApiErrors.md) and [`JsonApiErrorCodes`](api/JsonApiToolkit.Models.Errors/JsonApiErrorCodes.md). Use those codes in client code to handle errors programmatically.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 51abf43..363882c 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -9,25 +9,6 @@ This guide walks you through installing and configuring JsonApiToolkit in your .
## Installation
-To install this package from Intility's GitHub Packages, add this to your NuGet.config file:
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-Then install the package via NuGet:
-
```bash
dotnet add package Intility.JsonApiToolkit
```
@@ -58,65 +39,59 @@ dotnet add package Intility.JsonApiToolkit
> [!TIP]
> See the [Security](security.md#query-complexity-limits) documentation for security options and [Performance](performance.md) for database projection.
-2. **Inheritance:**
- Derive your API controllers from the provided `JsonApiController` to leverage helper methods that return JSON:API compliant responses.
+2. **Inherit from `JsonApiController`:**
+ Your controller gets `JsonApiQueryAsync`, `JsonApiOk`, `JsonApiCreated`, etc. Convention is to declare a `ResourceType` constant per controller so the resource type string is defined once.
```csharp
+ [ApiController]
+ [Route("api/[controller]")]
public class BooksController : JsonApiController
{
- // Your endpoint implementations here
+ private const string ResourceType = "book";
+ private readonly AppDbContext _context;
+
+ public BooksController(AppDbContext context) => _context = context;
+
+ [HttpGet]
+ public async Task GetAllAsync()
+ {
+ return await JsonApiQueryAsync(_context.Books, ResourceType);
+ }
}
```
-3. **Configuration:**
- The toolkit automatically configures JSON serialization settings (camelCase properties, ignoring nulls, etc.) and adds the JSON:API media type to the supported output formatters.
-
-> [!NOTE]
-> Now your API is ready to return responses that fully comply with the JSON:API specification!
+3. **Configuration is automatic:**
+ `AddJsonApiToolkit()` registers JSON serialization settings (camelCase, ignore nulls) and the `application/vnd.api+json` media type.
+## Example
-## GitHub Actions
-To get fetch the package in your GitHub Actions workflow, add the following to your workflow file:
+With the controller above, this request:
-```yaml
-- name: Add Intility NuGet Package Source
- run: |
- dotnet nuget add source https://nuget.pkg.github.com/Intility/index.json \
- --name Intility \
- --username ${{ github.actor }} \
- --password ${{ secrets.GITHUB_TOKEN }} \
- --store-password-in-clear-text
+```
+GET /api/books?filter[title][like]=Hobbit&include=author&page[size]=5&sort=-publishedDate
```
-> [!IMPORTANT]
-> Your Github token must have the `read:packages` scope to access the package.
-
-## Dependabot
-To enable Dependabot to fetch the package, add the registry to your `dependabot.yml` file. Example:
-
-```yaml
-version: 2
-
-registries:
- intility:
- type: "nuget-feed"
- url: "https://nuget.pkg.github.com/Intility/index.json"
- username: "x-access-token"
- password: ${{ secrets.INTILITY_NUGET_DEPENDABOT }}
-
-updates:
- - package-ecosystem: "nuget"
- directory: "/"
- registries: "*"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 5
- commit-message:
- prefix: "🤖"
- labels:
- - "dependencies"
+Returns a JSON:API document:
+
+```json
+{
+ "data": [
+ {
+ "id": "1",
+ "type": "book",
+ "attributes": { "title": "The Hobbit", "publishedDate": "1937-09-21" },
+ "relationships": {
+ "author": { "data": { "id": "1", "type": "author" } }
+ }
+ }
+ ],
+ "included": [
+ { "id": "1", "type": "author", "attributes": { "name": "J.R.R. Tolkien" } }
+ ],
+ "meta": { "totalCount": 1, "totalPages": 1 },
+ "links": { "self": "...", "first": "...", "last": "..." }
+}
```
-> [!IMPORTANT]
-> You need to create a token with `read:packages` that you add to
-> your dependabot secrets.
+Filtering, sorting, pagination, and includes all happen without extra code. See [Querying](querying.md) for the full parameter reference and [Recipes](recipes.md) for common scenarios.
+
diff --git a/docs/index.md b/docs/index.md
index 45c8f79..d0cd609 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,16 +1,51 @@
# JsonApiToolkit
-JsonApiToolkit is a lightweight toolkit for implementing the [JSON:API specification](https://jsonapi.org/) in .NET applications.
+Build [JSON:API](https://jsonapi.org/) endpoints in ASP.NET Core.
-It streamlines common tasks like:
+## What is JSON:API?
-- **Mapping**, automatically converting your entities to JSON:API resource objects.
-- **Querying**, with filtering, sorting, pagination, and inclusion of related resources.
-- **Error handling**, standardizing responses for a consistent client experience.
-- **Content negotiation**, supporting `application/vnd.api+json` out of the box.
+[JSON:API](https://jsonapi.org/) is a specification for how a client should request and modify resources, and how a server should respond. It standardizes filtering, sorting, pagination, sparse fieldsets, and including related resources, so every API doesn't reinvent its own query syntax.
+
+A typical JSON:API request looks like:
+
+```
+GET /api/books?filter[title][like]=Hobbit&include=author&fields[book]=title&page[size]=10&sort=-published
+```
+
+The response is a structured document with `data`, `included`, `meta`, `links`, and `errors` sections.
+
+## What this toolkit does
+
+JsonApiToolkit translates JSON:API query parameters into typed EF Core queries and returns spec-compliant response documents, so your controllers stay short:
+
+```csharp
+public class BooksController : JsonApiController
+{
+ private const string ResourceType = "book";
+
+ [HttpGet]
+ public async Task GetAllAsync()
+ {
+ return await JsonApiQueryAsync(_db.Books, ResourceType);
+ }
+}
+```
+
+Filtering, sorting, includes, sparse fieldsets, and pagination all work without extra code.
+
+## When to use it
+
+Reach for JsonApiToolkit when:
+
+- You want a standard query syntax across endpoints instead of bespoke filter parameters.
+- Your data model maps cleanly to resources with relationships (typical CRUD APIs over EF Core).
+- You want consistent error envelopes and content negotiation without writing them yourself.
+
+It's a less opinionated alternative to [JsonApiDotNetCore](https://github.com/json-api-dotnet/JsonApiDotNetCore): you keep your own controllers, DbContext, and routing, and the toolkit slots in where you want JSON:API semantics. If you need full convention-over-configuration with auto-generated routes and resource definitions, [JsonApiDotNetCore](https://github.com/json-api-dotnet/JsonApiDotNetCore) may fit better.
## Where to start
-- New here? Read the [Introduction](introduction.md) and [Getting Started](getting-started.md) guides.
-- Already integrated? Jump to [Querying](querying.md) or [Building Custom Queries](build-query.md).
-- Upgrading? See the [Upgrade Guide](upgrade-guide.md).
+- New here? [Getting Started](getting-started.md) walks you through installing and registering the toolkit.
+- Building queries? [Querying](querying.md) covers the supported parameters, [Building Custom Queries](build-query.md) covers exports and aggregations.
+- Locking it down? [Security](security.md) covers `[AllowedIncludes]` and query complexity limits.
+- Stuck? [Troubleshooting](troubleshooting.md) covers logging and common pitfalls.
diff --git a/docs/integrations/.pages b/docs/integrations/.pages
deleted file mode 100644
index 77ff941..0000000
--- a/docs/integrations/.pages
+++ /dev/null
@@ -1,4 +0,0 @@
-title: Integrations
-nav:
- - open-api.md
- - ts-tools.md
diff --git a/docs/integrations/ts-tools.md b/docs/integrations/ts-tools.md
deleted file mode 100644
index 7201401..0000000
--- a/docs/integrations/ts-tools.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# Integrations / Frontend Consumption
-
-# jsonapi-ts-tools
-
-**jsonapi-ts-tools** is a lightweight, Deno-based TypeScript library designed
-to make working with
-[JsonApiToolkit](https://github.com/intility/Intility.JsonApiToolkit) responses
-in TypeScript applications easier.
-
-## Features
-
-- **Type-safe**: Uses your own TypeScript types for attributes and relationships.
-- **Hydration**: Hydrate your API responses into TypeScript objects.
-- **Query Builder**: Generate query strings for filtering, sorting, pagination,
- and inclusion.
-
-## Prerequisites
-- **JsonApiToolkit**: This library is designed to work with [JsonApiToolkit](https://github.com/intility/Intility.JsonApiToolkit). Make sure the api you want to interact with is using JsonApiToolkit.
-
-## Getting Started
-
-You can read more about jsonapi-ts-tools & JsonApiToolkit [**here**](https://intility.github.io/Intility.JsonApiToolkit/docs/integrations/ts-tools.html), or follow the instructions below for a quick start.
-
-### Installation
-
-```bash
-npm install @intility/jsonapi-ts-tools
-```
-
-### Define your types
-
-```ts
-export interface Todo {
- id: string;
- type: 'todo';
- title: string;
- completed: boolean;
- dueDate: string;
- owner: User;
- tags: Tag[];
-}
-
-export interface User {
- id: string;
- type: 'user';
- name: string;
- email: string;
-}
-
-export interface Tag {
- id: string;
- type: 'tag';
- label: string;
-}
-```
-
-### Create a hook for hydrated JSON:API responses
-This library has zero dependencies, therefore no hook is provided. You can use any library to fetch data, but here is an example using `react-query`:
-
-```ts
-import { QueryKey, useQuery, UseQueryOptions } from '@tanstack/react-query';
-import {
- HydratedQueryResult,
- hydrateResponse,
- JsonApiResponse,
-} from '@intility/jsonapi-ts-tools';
-
-export function useHydratedQuery(
- queryKey: QueryKey,
- options?: Omit<
- UseQueryOptions>,
- 'queryKey' | 'select'
- >,
-) {
- return useQuery>({
- queryKey,
- select: (data) => hydrateResponse(data),
- ...options,
- });
-}
-```
-
-Then in your component, you can use the `useHydratedQuery` hook to fetch and automatically hydrate your data. Make sure to pass the correct type for the hydrated data.
-
-```ts
-const { data, isLoading } = useHydratedQuery(
- [queryKey, 'todos', queryString],
-);
-```
-
-> [!IMPORTANT]
-> This example uses a default `queryFn` which is pointed at with the `queryKey`. You can use any `queryFn` you want.
-
-Now you can use the `data` object in your component.
-
-```tsx
-{isLoading ? Loading…
: (
-
-)}
-```
-
-### Query builder
-In addition to hydrating your data, you can use the query builder to create query strings for filtering, sorting, pagination, and inclusion. Append
-
-```ts
-const queryString = new JsonApiQueryBuilder()
- .filter('completed', true)
- .sort('dueDate')
- .include('owner', 'tags')
- .page(1, 10)
- .build();
-```
-
-This will create a query string that looks like this:
-
-```
-?filter[completed]=true&sort=dueDate&page[number]=1&page[size]=10&include=owner,tags
-```
-
-> [!TIP]
-> The query builder supports nested includes using dot notation.
-
-
-### Kitchen sink example
-
-**What this query does**
-
-- Filters for incomplete todos (`completed = false`)
-- Filters for todos due between today and 7 days from now **OR** tagged as "urgent"
-- Filters for todos owned by Alice **or** Bob, **and** where `dueDate` is not null
-- Sorts by `dueDate` ascending, then by `title` descending
-- Includes related `owner`, `tags`, and the owner's `email`
-- Requests page 2, 20 items per page
-
----
-
-**Query builder code**
-
-```ts
-const queryString = new JsonApiQueryBuilder()
- .filter('completed', 'eq', false)
- .or(or =>
- or
- .filter('dueDate', 'ge', '2025-05-21')
- .filter('dueDate', 'le', '2025-05-28')
- .or(or2 =>
- or2.filter('tags', 'in', ['urgent'])
- )
- )
- .and(and =>
- and
- .or(or =>
- or
- .filter('owner', 'eq', 'alice-id')
- .filter('owner', 'eq', 'bob-id')
- )
- .not(not =>
- not.filter('dueDate', 'isnull', true)
- )
- )
- .sort('dueDate', '-title')
- .include('owner', 'tags', 'owner.email')
- .paginate(2, 20)
- .build();
-```
-
----
-
-**Output query string**
-
-```
-filter[completed]=false
-&filter[or][0][dueDate][ge]=2025-05-21
-&filter[or][1][dueDate][le]=2025-05-28
-&filter[or][2][or][0][tags][in]=urgent
-&filter[and][0][or][0][owner][eq]=alice-id
-&filter[and][0][or][1][owner][eq]=bob-id
-&filter[and][1][not][0][dueDate][isnull]=true
-&sort=dueDate,-title
-&include=owner,tags,owner.email
-&page[number]=2
-&page[size]=20
-```
-
-### Further Information
-
-For more details on the package itself, visit the repository:
-
-* **GitHub:** [jsonapi-ts-tools](https://github.com/intility/jsonapi-ts-tools)
-
diff --git a/docs/introduction.md b/docs/introduction.md
deleted file mode 100644
index 5cfa9ba..0000000
--- a/docs/introduction.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Introduction
-
-JsonApiToolkit is a lightweight toolkit for implementing the JSON:API specification in .NET applications. It streamlines the process of exposing resources in a standardized format by providing built‐in support for:
-
-- **Mapping:** Converts entities into JSON:API resource objects.
-- **Querying:** Applies advanced filtering, sorting, pagination, and inclusion of related resources.
-- **Error Handling:** Returns consistent and compliant error responses.
-- **Content Negotiation:** Supports the `application/vnd.api+json` media type out-of-the-box.
-
-This toolkit is ideal for API developers who want to quickly build RESTful services that conform to JSON:API standards without reinventing the wheel.
-
-**Key Features:**
-- Easy configuration and integration via dependency injection.
-- Strongly typed LINQ query extensions for filtering and sorting.
-- Built-in pagination with self, first, last, prev, and next links.
-- Comprehensive support for error response formatting.
-- A robust parsing system for all JSON:API query parameters.
-
-Whether you’re building a new API or modernizing an existing one, JsonApiToolkit provides the tools needed to create clean, consistent, and extensible APIs in .NET.
diff --git a/docs/integrations/open-api.md b/docs/openapi.md
similarity index 100%
rename from docs/integrations/open-api.md
rename to docs/openapi.md
diff --git a/docs/querying.md b/docs/querying.md
index 0a36591..2489c20 100644
--- a/docs/querying.md
+++ b/docs/querying.md
@@ -1,138 +1,88 @@
# Querying
-JsonApiToolkit provides robust support for JSON:API querying, including filtering, sorting, pagination, and including related resources.
-
-## Supported Query Parameters
-
-- **Filtering (`filter`):**
- Use the `filter` parameter to narrow down results. You can specify simple filters or advanced filters with operators.
-
- **Simple Filters:**
- - `GET /api/books?filter[title]=The Hobbit`
-
- **Advanced Filters with Operators:**
- - `eq` (equal): `GET /api/books?filter[price][eq]=10`
- - `ne` (not equal): `GET /api/books?filter[status][ne]=archived`
- - `gt` (greater than): `GET /api/books?filter[price][gt]=10`
- - `ge` (greater than or equal): `GET /api/books?filter[price][ge]=10`
- - `lt` (less than): `GET /api/books?filter[price][lt]=50`
- - `le` (less than or equal): `GET /api/books?filter[price][le]=50`
- - `like` (contains): `GET /api/books?filter[title][like]=Hobbit`
- - `in` (in list): `GET /api/books?filter[genre][in]=fiction,fantasy,mystery`
- - `nin` (not in list): `GET /api/books?filter[status][nin]=archived,deleted`
- - `isnull` (is null): `GET /api/books?filter[description][isnull]=true`
- - `isnotnull` (is not null): `GET /api/books?filter[description][isnotnull]=true`
-
- **Logical Groups:**
- - AND groups: `GET /api/books?filter[and][0][price][gt]=10&filter[and][1][genre]=fiction`
- - OR groups: `GET /api/books?filter[or][0][title][like]=Hobbit&filter[or][1][author]=Tolkien`
- - NOT groups: `GET /api/books?filter[not][0][status]=archived`
-
-- **Sorting (`sort`):**
- The `sort` parameter allows multiple sort criteria. Prefixing a field with a minus (`-`) indicates descending order.
- - Example: `GET /api/books?sort=title,-publishedDate`
-
-- **Pagination (`page[number]` and `page[size]`):**
- Control how many results are returned and which page to view.
- - Example: `GET /api/books?page[number]=2&page[size]=5`
- - By default, invalid pagination values (page number less than 1, page size exceeding `MaxPageSize`) are silently clamped to valid ranges. Enable `StrictPagination` to return 400 errors instead (see [Security](security.md#strict-pagination)).
-
-- **Inclusion (`include`):**
- Specify which related resources should be included in the response.
- - Example: `GET /api/books?include=author,reviews`
-
-- **Relationship-Based Primary Filtering (Dot Notation):**
- Filter the **primary resource** based on attributes of related resources using dot notation. This is useful when you want to find records based on their relationships.
-
- - `GET /api/books?filter[author.country][eq]=UK` - Returns only books by UK authors
- - `GET /api/books?filter[publisher.name][like]=Penguin` - Returns only books from publishers containing "Penguin"
- - OR chains: `GET /api/books?filter[or][0][author.country][eq]=UK&filter[or][1][author.country][eq]=US`
+JsonApiToolkit parses standard JSON:API query parameters and applies them to your `IQueryable` automatically when you call `JsonApiQueryAsync`.
-> [!NOTE]
-> Dot notation filters always apply to the **primary resource**, even when the relationship is included in the response. The related data is still included, but only matching primary records are returned.
+## Filtering
-- **Filtering on Includes (Bracket Syntax):**
- Filter **included resources** using bracket syntax. This applies filters directly to what gets included, not what primary records are returned.
+`filter[field]=value` for simple equality, or `filter[field][operator]=value` for operators.
- - `GET /api/books?include=reviews&filter[reviews][status][eq]=approved` - Returns all books, but only includes approved reviews
- - Complex filters: `GET /api/books?include=reviews&filter[or][0][reviews][rating][gte]=4&filter[or][1][reviews][featured][eq]=true`
- - Nested includes: `GET /api/authors?include=books,books.reviews&filter[reviews][verified][eq]=true`
+| Operator | Meaning | Example |
+|----------|---------|---------|
+| `eq` | equal | `filter[price][eq]=10` |
+| `ne` | not equal | `filter[status][ne]=archived` |
+| `gt` / `ge` | greater than (or equal) | `filter[price][gt]=10` |
+| `lt` / `le` | less than (or equal) | `filter[price][le]=50` |
+| `like` | contains | `filter[title][like]=Hobbit` |
+| `in` / `nin` | (not) in list | `filter[genre][in]=fiction,fantasy` |
+| `isnull` / `isnotnull` | null check | `filter[description][isnull]=true` |
-> [!TIP]
-> **Syntax Summary:**
-> - `filter[relationship.field][op]=value` (dot notation) → Filters the **primary resource**
-> - `filter[relationship][field][op]=value` (bracket syntax) → Filters **included resources**
+### Logical groups
-> [!NOTE]
-> Filtered includes currently support up to 2-level nesting (e.g., `parent.child`). Deeper nesting will fall back to unfiltered includes.
+Combine filters with AND, OR, NOT blocks:
-- **Sparse Fieldsets (`fields[type]`):**
- Request only specific attributes per resource type to reduce response payload size. The `id` and `type` fields are always returned. Relationships are not affected by sparse fieldsets.
+```
+filter[and][0][price][gt]=10&filter[and][1][genre]=fiction
+filter[or][0][title][like]=Hobbit&filter[or][1][author]=Tolkien
+filter[not][0][status]=archived
+```
- - Single type: `GET /api/books?fields[books]=title,publishedDate`
- - Multiple types: `GET /api/books?fields[books]=title&fields[author]=name&include=author`
- - Combined: `GET /api/books?fields[books]=title,genre&sort=-publishedDate&page[number]=1&page[size]=10`
+### Filtering across relationships
-> [!NOTE]
-> By default, sparse fieldsets filter attributes at serialization time. The database still loads full entities.
-> To also optimize the database query (only fetch requested columns), enable `EnableDatabaseProjection`:
-> ```csharp
-> services.AddJsonApiToolkit(options => options.EnableDatabaseProjection = true);
-> ```
-> See [Performance](performance.md) for details.
+Two distinct shapes do different things:
-## How It Works
+- **Dot notation** filters the **primary resource** by a related field:
+ `filter[author.country][eq]=UK` returns only books whose author's country is UK.
+- **Bracket syntax** filters **included resources**:
+ `include=reviews&filter[reviews][status][eq]=approved` returns all books, but each book's `reviews` only contains approved ones.
-JsonApiToolkit automatically parses these parameters through its built-in query parser and applies them to your Entity Framework queries. This is accomplished by extension methods such as:
+> [!NOTE]
+> Filtered includes (bracket syntax) work up to two levels deep (`parent.child`). Filters at three or more levels return 400 Bad Request. This limit is not configurable.
-- `ApplyFilters`
-- `ApplySorting`
-- `ApplyPagination`
-- `ApplyJsonApiParameters`
+## Sorting
-These methods allow you to take a raw IQueryable and layer on the JSON:API query conventions seamlessly.
+`sort=field` ascending, `sort=-field` descending, comma-separated for multi-field:
-## Example
+```
+sort=title,-publishedDate
+```
-A typical query URL might look like:
+## Pagination
```
-GET /api/books?filter[title][like]=Hobbit&sort=-publishedDate&page[number]=1&page[size]=10&include=author,reviews
+page[number]=2&page[size]=10
```
-With this request, the toolkit will:
-- Filter books to those whose title contains "Hobbit".
-- Sort the results with the newest published books first.
-- Return the first 10 results.
-- Include related author and reviews data in the response.
+By default, invalid values are silently clamped (`page[number]=0` → 1, oversized → `MaxPageSize`). Enable `StrictPagination` to return errors instead. See [Security](security.md#strict-pagination).
-**Note:**
-- Filters without dot notation apply only to the main resource type (books in this example).
-- Filters with **dot notation** (e.g., `filter[author.name][eq]=Tolkien`) filter the **primary resource** based on relationship attributes.
-- Filters with **bracket syntax** (e.g., `filter[reviews][status][eq]=approved`) filter **what gets included** in the response.
+## Includes
-## Limitations
+`include=author,reviews` for top-level relationships, dot-separated for nesting:
-JsonApiToolkit enforces the following query limits to ensure predictable performance and security:
+```
+include=author,publisher.country
+```
+
+Without `[AllowedIncludes]`, every relationship is includable. See [Security](security.md#allowedincludes) to restrict which.
+
+## Sparse fieldsets
-| Limit | Default | Description |
-|-------|---------|-------------|
-| Max Filters | 50 | Maximum number of filter conditions |
-| Max Filter Groups | 10 | Maximum OR/NOT logical blocks |
-| Max Filter Depth | 3 | Maximum nesting of filter groups |
-| Max Filter Value Length | 1000 | Maximum characters per filter value |
-| Max Include Depth | 3 | Maximum include path depth (e.g., `author.posts.comments`) |
-| Max Page Size | 100 | Maximum items per page (clamped or rejected based on `StrictPagination`) |
+`fields[type]=field1,field2` returns only those attributes per resource type. `id` and `type` are always returned; relationships are unaffected.
-These limits are configurable via `JsonApiOptions`. See the [Security](security.md#query-complexity-limits) documentation for configuration details.
+```
+fields[books]=title,publishedDate
+fields[books]=title&fields[author]=name&include=author
+```
-**Additional limitations:**
+By default, sparse fieldsets only filter at serialization time; EF Core still loads full entities. Set `EnableDatabaseProjection = true` to push the projection into the SQL `SELECT`. See [Performance](performance.md).
-- **Include filter validation**: Filters with dot notation can only be applied to relationships that are explicitly included in the request. Use the `AllowedIncludesAttribute` to control which relationships can be filtered.
-- **Filtered includes nesting**: Filtered includes currently support up to 2-level nesting (e.g., `parent.child`). Deeper nesting will fall back to unfiltered includes.
+## Putting it together
-## Attribute Mapping
+```
+GET /api/books?filter[title][like]=Hobbit&sort=-publishedDate&page[size]=10&include=author,reviews
+```
-The primary ID property is automatically excluded from attributes since it's already present in the resource's `"id"` field.
+This filters by title, sorts newest first, returns 10 results, and eager-loads `author` and `reviews`.
+## Limits
+Query complexity limits (max filters, max include depth, max page size, etc.) are configurable via `JsonApiOptions`. See [Security](security.md#query-complexity-limits) for the full table and behavior.
diff --git a/docs/recipes.md b/docs/recipes.md
new file mode 100644
index 0000000..28401ab
--- /dev/null
+++ b/docs/recipes.md
@@ -0,0 +1,135 @@
+# Recipes
+
+Common scenarios with JsonApiToolkit. For setup, see [Getting Started](getting-started.md).
+
+## Single resource by id
+
+```csharp
+[HttpGet("{id}")]
+public async Task GetBook(int id)
+{
+ var book = await _db.Books.FirstOrDefaultAsync(b => b.Id == id)
+ ?? throw new JsonApiNotFoundException($"Book {id} not found");
+
+ return JsonApiOk(book, ResourceType);
+}
+```
+
+`JsonApiOk` wraps an already-loaded entity into a JSON:API document. Use it when you've fetched data yourself; use `JsonApiQueryAsync` when you want filtering/sorting/pagination applied automatically.
+
+## Create a resource
+
+```csharp
+[HttpPost]
+public async Task CreateBook([FromBody] CreateBookRequest request)
+{
+ if (string.IsNullOrWhiteSpace(request.Title))
+ throw new JsonApiBadRequestException("Title is required");
+
+ var book = new Book { Title = request.Title, Author = request.Author };
+ _db.Books.Add(book);
+ await _db.SaveChangesAsync();
+
+ return JsonApiCreated(book, ResourceType, book.Id.ToString());
+}
+```
+
+## Update or delete
+
+```csharp
+[HttpPut("{id}")]
+public async Task UpdateBook(int id, [FromBody] UpdateBookRequest request)
+{
+ var book = await _db.Books.FindAsync(id)
+ ?? throw new JsonApiNotFoundException($"Book {id} not found");
+
+ book.Title = request.Title ?? book.Title;
+ await _db.SaveChangesAsync();
+
+ return JsonApiOk(book, ResourceType);
+}
+
+[HttpDelete("{id}")]
+public async Task DeleteBook(int id)
+{
+ var book = await _db.Books.FindAsync(id)
+ ?? throw new JsonApiNotFoundException($"Book {id} not found");
+
+ _db.Books.Remove(book);
+ await _db.SaveChangesAsync();
+
+ return JsonApiNoContent();
+}
+```
+
+Always throw the `JsonApi*Exception` types instead of returning error `ActionResult`s. The toolkit's exception filter turns them into JSON:API error responses with the right status code.
+
+## Layer business logic before query processing
+
+```csharp
+[HttpGet]
+public async Task GetPublishedAsync()
+{
+ var query = _db.Books.Where(b => b.IsPublished);
+ return await JsonApiQueryAsync(query, ResourceType);
+}
+```
+
+Apply your own `Where` clauses to the `IQueryable` before handing it to `JsonApiQueryAsync`. The toolkit layers user filters, includes, sorting, and pagination on top.
+
+## Custom DTO with manual mapping
+
+```csharp
+[HttpGet("{id}/summary")]
+public async Task GetBookSummary(int id)
+{
+ var book = await _db.Books.Include(b => b.Author).FirstOrDefaultAsync(b => b.Id == id)
+ ?? throw new JsonApiNotFoundException($"Book {id} not found");
+
+ var summary = new BookSummary
+ {
+ Id = book.Id,
+ Title = book.Title,
+ AuthorName = book.Author?.Name
+ };
+
+ return JsonApiOk(summary, "bookSummary");
+}
+```
+
+The resource type (second argument) is whatever string you want clients to see; it doesn't have to match the entity name. Define a separate `const` when an action returns a different resource type than the controller's default.
+
+## Restrict which relationships clients can include
+
+```csharp
+[HttpGet]
+[AllowedIncludes("author", "reviews")]
+public async Task GetAllAsync()
+{
+ return await JsonApiQueryAsync(_db.Books, ResourceType);
+}
+```
+
+Without the attribute, every relationship is includable. See [Security](security.md) for wildcard patterns and filter-path validation.
+
+## Filter included resources
+
+```
+GET /api/books?include=reviews&filter[reviews][status][eq]=approved
+```
+
+This returns all books, but each book's `reviews` collection only contains approved reviews. Up to two levels of nesting is supported (`parent.child`). See [Querying](querying.md) for the full filter syntax.
+
+## Export all matching results (no pagination)
+
+```csharp
+[HttpGet("export")]
+public async Task ExportBooks()
+{
+ var result = await BuildJsonApiQueryAsync(_db.Books.AsNoTracking(), ResourceType);
+ var books = await result.Query.ToListAsync();
+ // serialize to CSV / Excel / etc.
+}
+```
+
+`BuildJsonApiQueryAsync` applies filters, includes, and sorting but skips pagination, so you get the full filtered set. See [Building Custom Queries](build-query.md) for projections and aggregations.
diff --git a/docs/security.md b/docs/security.md
index 69b7cac..cf6a848 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1,119 +1,69 @@
# Security
-JsonApiToolkit provides security features to control which relationships can be included in JSON:API responses.
+Guidance for using JsonApiToolkit safely: restricting includes, configuring query complexity limits, and rejecting bad pagination.
-## AllowedIncludes Attribute
-
-The `[AllowedIncludes]` attribute restricts which relationships clients can request via the `include` query parameter. This prevents exposure of sensitive relationships and protects against potentially expensive queries.
+> [!NOTE]
+> To **report a vulnerability** in JsonApiToolkit itself, see the [Security Policy](https://github.com/intility/json-api-toolkit/blob/main/SECURITY.md). This page is about using the toolkit securely.
-### Basic Usage
+## `[AllowedIncludes]`
-Apply the attribute to controller actions:
+Without `[AllowedIncludes]`, every navigation property on your entities is includable via `?include=`. That can leak sensitive relationships and run expensive queries. The attribute restricts which relationships clients can request.
```csharp
-[HttpGet("users")]
-[AllowedIncludes("profile", "posts")]
-public async Task GetUsers()
+[HttpGet]
+[AllowedIncludes("author", "reviews")]
+public async Task GetAllAsync()
{
- return await JsonApiQueryAsync(_context.Users, "user");
+ return await JsonApiQueryAsync(_db.Books, ResourceType);
}
```
-### Wildcard Patterns
+Forbidden includes return 403 with the requested, forbidden, and allowed lists in `meta`.
-Use wildcards to allow nested includes at specific levels:
+### Wildcards
```csharp
-[HttpGet("posts")]
[AllowedIncludes("author.*", "comments")]
-public async Task GetPosts()
-{
- return await JsonApiQueryAsync(_context.Posts, "post");
-}
-```
-
-**Wildcard Rules:**
-- `author.*` allows `author` and `author.profile` but not `author.profile.settings`
-- `*` allows all top-level includes but no nested ones
-
-### Configuration Options
-
-**Empty array** - No includes allowed:
-```csharp
-[AllowedIncludes()]
-```
-
-**No attribute** - All includes allowed (default behavior)
-
-### Error Responses
-
-When forbidden includes are requested, a 403 Forbidden response is returned:
-
-```json
-{
- "errors": [{
- "status": "403",
- "title": "Forbidden Include",
- "detail": "The requested include 'sensitive' was not found",
- "meta": {
- "requestedIncludes": ["profile", "sensitive"],
- "forbiddenIncludes": ["sensitive"],
- "allowedIncludes": ["profile", "posts"]
- }
- }]
-}
```
-### Case Sensitivity
-
-All matching is case-insensitive:
-- `Author` matches `author`
-- `author.*` matches `Author.Posts`
+- `author.*` allows `author` and `author.profile`, but not `author.profile.settings`.
+- `*` allows any top-level include, but no nesting.
+- `[AllowedIncludes()]` (empty) allows no includes at all.
-### Pattern Validation
-
-Invalid patterns are logged as warnings during application startup:
-
-```
-AllowedIncludesAttribute validation warnings for UsersController.GetUsers:
-Pattern 'user.**' contains '**' which is not supported. Use single '*' for wildcards.
-```
+Matching is case-insensitive. Invalid patterns (e.g., `**`) log a warning at startup.
-## Query Complexity Limits
+### Filter-path validation
-JsonApiToolkit enforces configurable limits on query complexity to prevent resource exhaustion attacks.
+Dot-notation filter paths are validated against the same list. With `[AllowedIncludes("author")]`, `filter[author.name]=John` works but `filter[admin.role]=X` returns 403. This only kicks in when the attribute is present. Without it, all filter paths are allowed.
-### Configuration
+## Query complexity limits
-Configure limits via `JsonApiOptions` in your `Program.cs`:
+The toolkit enforces configurable limits on query complexity to prevent resource exhaustion. Configure them via `JsonApiOptions`:
```csharp
-builder.Services.AddJsonApiToolkit(options => {
- options.MaxFilters = 50; // Max filter conditions (default: 50)
- options.MaxFilterGroups = 10; // Max OR/NOT blocks (default: 10)
- options.MaxFilterDepth = 3; // Max group nesting depth (default: 3)
- options.MaxFilterValueLength = 1000; // Max value string length (default: 1000)
- options.MaxIncludeDepth = 3; // Max include path depth (default: 3)
- options.MaxPageSize = 100; // Max page size, clamped (default: 100)
- options.DefaultPageSize = 10; // Default when not specified (default: 10)
- options.StrictPagination = false; // Reject invalid pagination (default: false)
+builder.Services.AddJsonApiToolkit(options =>
+{
+ options.MaxFilters = 50; // max filter conditions
+ options.MaxFilterGroups = 10; // max OR/NOT blocks
+ options.MaxFilterDepth = 3; // max group nesting
+ options.MaxFilterValueLength = 1000; // max value string length
+ options.MaxIncludeDepth = 3; // max include path depth
+ options.MaxPageSize = 100; // max items per page
+ options.DefaultPageSize = 10;
+ options.StrictPagination = false; // reject vs clamp invalid pagination
});
```
-### Limit Behaviors
-
-| Option | Behavior When Exceeded |
-|--------|----------------------|
-| `MaxFilters` | Returns 400 Bad Request |
-| `MaxFilterGroups` | Returns 400 Bad Request |
-| `MaxFilterDepth` | Returns 400 Bad Request |
-| `MaxFilterValueLength` | Returns 400 Bad Request |
-| `MaxIncludeDepth` | Returns 400 Bad Request |
-| `MaxPageSize` | Clamped (default) or 400 Bad Request (`StrictPagination`) |
+| Limit | Default | Behavior when exceeded |
+|-------|---------|------------------------|
+| `MaxFilters` | 50 | 400 Bad Request |
+| `MaxFilterGroups` | 10 | 400 Bad Request |
+| `MaxFilterDepth` | 3 | 400 Bad Request |
+| `MaxFilterValueLength` | 1000 | 400 Bad Request |
+| `MaxIncludeDepth` | 3 | 400 Bad Request |
+| `MaxPageSize` | 100 | Clamped, or 400 if `StrictPagination` |
-### Error Response
-
-When limits are exceeded, a 400 Bad Request is returned with details:
+Exceeded limits return:
```json
{
@@ -121,85 +71,28 @@ When limits are exceeded, a 400 Bad Request is returned with details:
"status": "400",
"code": "QUERY_TOO_COMPLEX",
"title": "Query exceeds complexity limits",
- "detail": "Query contains 75 filters, but maximum allowed is 50. Reduce filter count or configure a higher limit via JsonApiOptions.MaxFilters.",
+ "detail": "Query contains 75 filters, but maximum allowed is 50.",
"source": { "parameter": "filter" },
- "meta": {
- "limit": 50,
- "actual": 75,
- "configKey": "JsonApiOptions.MaxFilters"
- }
+ "meta": { "limit": 50, "actual": 75, "configKey": "JsonApiOptions.MaxFilters" }
}]
}
```
-## Filter Path Validation
-
-When using `[AllowedIncludes]`, dot-notation filter paths are also validated against the allowed list.
+Raise the limits if your application genuinely needs them, but monitor query performance when you do.
-### How It Works
+## Strict pagination
-If you use `filter[author.name]=John` (filtering the primary resource by a related entity's field), the `author` relationship must be in the `AllowedIncludes` list:
+By default, invalid pagination is silently clamped to valid ranges (page 0 → 1, page 99999 → last page, oversized page → `MaxPageSize`). Enable `StrictPagination` to return errors instead:
```csharp
-[HttpGet("posts")]
-[AllowedIncludes("author", "comments")]
-public async Task GetPosts()
-{
- // filter[author.name]=John ✓ - allowed
- // filter[author.bio]=X ✓ - allowed
- // filter[admin.role]=X ✗ - 403 Forbidden (admin not in AllowedIncludes)
- return await JsonApiQueryAsync(_context.Posts, "post");
-}
+builder.Services.AddJsonApiToolkit(options => options.StrictPagination = true);
```
-### Error Response
+With strict pagination on:
-When filtering on a non-allowed relationship:
-
-```json
-{
- "errors": [{
- "status": "403",
- "title": "Forbidden",
- "detail": "Filtering on relationship 'admin' is not allowed. Add 'admin' to AllowedIncludes or remove the filter."
- }]
-}
-```
-
-> [!NOTE]
-> This validation only applies when `[AllowedIncludes]` is present. Without the attribute, all filter paths are allowed.
-
-## Strict Pagination
-
-By default, JsonApiToolkit silently clamps invalid pagination parameters to valid ranges for backwards compatibility. Enable `StrictPagination` to return 400 Bad Request errors instead.
-
-### Configuration
-
-```csharp
-builder.Services.AddJsonApiToolkit(options => {
- options.StrictPagination = true;
-});
-```
-
-### Behavior
-
-When `StrictPagination` is enabled, the following values are rejected with 400 Bad Request at parse time:
-
-- `page[number]` less than 1 (e.g., `page[number]=0` or `page[number]=-5`)
-- `page[size]` less than 1 (e.g., `page[size]=0` or `page[size]=-10`)
-- `page[size]` exceeding `MaxPageSize` (e.g., `page[size]=200` when `MaxPageSize=100`)
-
-After the count is computed, the following also returns **404 Not Found**:
-
-- `page[number]` greater than the total page count (e.g., `page[number]=100` for a result set with 3 pages). Returns no 404 when the result set is empty (no pages exist).
-
-When disabled (default), these values are silently clamped:
-
-- `page[number]` less than 1 becomes 1
-- `page[number]` greater than total pages becomes the last page
-- `page[size]` exceeding `MaxPageSize` becomes `MaxPageSize`
-
-### Error Response
+- `page[number] < 1` → 400 Bad Request
+- `page[size] < 1` or `> MaxPageSize` → 400 Bad Request
+- `page[number]` greater than total pages → 404 Not Found (only when results exist)
```json
{
@@ -207,42 +100,9 @@ When disabled (default), these values are silently clamped:
"status": "400",
"code": "INVALID_PAGE_SIZE",
"title": "Invalid page size",
- "detail": "Page size '200' exceeds maximum allowed size of 100. Reduce page size or configure a higher limit via JsonApiOptions.MaxPageSize.",
+ "detail": "Page size '200' exceeds maximum allowed size of 100.",
"source": { "parameter": "page[size]" },
- "meta": {
- "value": 200,
- "max": 100,
- "configKey": "JsonApiOptions.MaxPageSize"
- }
+ "meta": { "value": 200, "max": 100, "configKey": "JsonApiOptions.MaxPageSize" }
}]
}
```
-
-## DoS Protection
-
-The query complexity limits protect against several denial-of-service attack vectors:
-
-### Resource Exhaustion
-Complex queries with many filters or deep nesting can cause excessive CPU usage. Limits on `MaxFilters`, `MaxFilterGroups`, and `MaxFilterDepth` prevent attackers from crafting queries that overwhelm the server.
-
-### Stack Overflow
-Deeply nested filter groups could cause stack overflow during recursive expression building. The `MaxFilterDepth` limit and internal recursion guards prevent this.
-
-### Memory Exhaustion
-Very large filter values or responses could exhaust server memory. The `MaxFilterValueLength` and `MaxPageSize` limits bound memory usage per request.
-
-### Recommended Defaults
-
-The default limits are designed to handle typical API usage while blocking abuse:
-
-| Limit | Default | Rationale |
-|-------|---------|-----------|
-| MaxFilters | 50 | Covers complex search UIs |
-| MaxFilterGroups | 10 | Allows OR chains for search |
-| MaxFilterDepth | 3 | Entity → Relationship → Property |
-| MaxFilterValueLength | 1000 | Long enough for UUIDs, GUIDs, text search |
-| MaxIncludeDepth | 3 | Matches filter depth |
-| MaxPageSize | 100 | Prevents large data dumps |
-
-> [!TIP]
-> If your application requires higher limits, increase them via configuration. Monitor query performance when raising limits.
\ No newline at end of file
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..3839292
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,57 @@
+# Troubleshooting
+
+## Enable logging
+
+For Serilog:
+
+```json
+{
+ "Serilog": {
+ "MinimumLevel": {
+ "Override": {
+ "JsonApiToolkit": "Debug",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ }
+ }
+ }
+}
+```
+
+For `Microsoft.Extensions.Logging`:
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "JsonApiToolkit": "Debug",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ }
+ }
+}
+```
+
+The toolkit emits:
+
+- **Information**: empty result explanations, complex queries (>20 filters), execution summaries.
+- **Warning**: invalid property/field names, parameter parsing issues, large unpaginated results (>1000), include mapping problems.
+- **Debug**: query summaries, include strategy (single vs split query), filter/sort/include/pagination details.
+
+EF Core's `Microsoft.EntityFrameworkCore.Database.Command` logger at `Information` shows the actual SQL.
+
+## Common pitfalls
+
+### Filter looks wrong but returns 200 with no error
+
+Malformed filter, sort, and include parameters are logged at warning level and silently skipped, not thrown. Check your logs at `Warning` level for `JsonApiToolkit`. You'll see exactly what was rejected.
+
+### Filter on an aggregation DTO is ignored
+
+If you pass an anonymous projection or DTO to `JsonApiQueryAsync`, filters target the DTO's properties, not the source entity. A filter referencing a column the DTO doesn't expose is silently skipped. Use `BuildJsonApiQueryAsync` (or `ApplyFiltersOnly`) to filter the source `IQueryable` *before* aggregating. See [Building Custom Queries](build-query.md).
+
+### Filtered include only works two levels deep
+
+`filter[parent][field]=x` and `filter[parent.child][field]=x` work. A filter at three or more levels returns 400 Bad Request with `JsonApiBadRequestException`. The limit is hard-coded for query plan stability and isn't configurable. Restructure the request to filter the primary resource (dot notation) instead.
+
+### Page number out of range returns clamped data instead of an error
+
+By default, invalid page numbers are silently clamped: `page[number]=0` becomes 1, `page[number]=99999` becomes the last page. Enable `StrictPagination` (see [Security](security.md#strict-pagination)) to return 400/404 instead.
diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md
deleted file mode 100644
index 5caf543..0000000
--- a/docs/upgrade-guide.md
+++ /dev/null
@@ -1,368 +0,0 @@
-# Upgrade Guide
-
-This document tracks all breaking changes, new features, and migration steps for each version of JsonApiToolkit.
-
-**Current Version:** 2.1.0
-
----
-
-## v2.1.0 - Strict Pagination (runtime)
-
-**New Behavior:**
-`StrictPagination` now also rejects requests for a page that does not exist (page number greater than the total number of pages). Previously these requests were clamped to the last page.
-
-When `StrictPagination = true` and the requested `page[number]` exceeds the total page count for the resolved query, the controller returns **404 Not Found** with a JSON:API error document:
-
-```json
-{
- "errors": [{
- "status": "404",
- "code": "INVALID_PAGE_NUMBER",
- "detail": "Page 10 does not exist. This collection has 3 page(s). Request a page between 1 and 3.",
- "source": { "parameter": "page[number]" },
- "meta": {
- "value": 10,
- "totalPages": 3,
- "totalResources": 5
- }
- }]
-}
-```
-
-**Edge case:** When a query returns zero resources (for example, a filter that matches nothing), no 404 is raised regardless of the requested page number. There are no pages to be wrong about.
-
-**Default behavior (`StrictPagination = false`) is unchanged**: out-of-range page numbers are clamped to the last page.
-
-**Breaking Changes:** None. Behavior change is opt-in via `StrictPagination`. Parse-time `StrictPagination` errors (invalid page number, invalid page size, page size exceeds maximum) introduced earlier are unchanged.
-
----
-
-## v2.0.0 - .NET 10
-
-**Breaking Changes:**
-- Minimum runtime requirement changes from .NET 9 to .NET 10
-- Applications must target `net10.0` to use this version
-
-**Changes:**
-- Target framework updated from `net9.0` to `net10.0`
-- `Microsoft.EntityFrameworkCore` updated to 10.x
-- `Microsoft.EntityFrameworkCore.Relational` updated to 10.x
-- Replaced legacy `Microsoft.AspNetCore.Mvc` 2.x NuGet package with `FrameworkReference` to `Microsoft.AspNetCore.App` (the correct pattern since .NET Core 3.0)
-- Removed explicit `Microsoft.AspNetCore.JsonPatch` and `Microsoft.Extensions.DependencyInjection.Abstractions` package references (provided by the shared framework)
-- CI/CD pipelines updated to .NET 10 SDK
-- SDK version pinned via `global.json` for reproducible builds
-
-**Migration:**
-1. Update your project to target .NET 10:
- ```xml
- net10.0
- ```
-2. Update the JsonApiToolkit package to v2.0.0
-3. Ensure your deployment environment has the .NET 10 runtime installed
-
----
-
-## v1.8.0 - Database Projection
-
-**New Features:**
-- Database-level column filtering via EF Core `Select()` projection.
- When `fields[type]` is specified and `EnableDatabaseProjection` is enabled, the toolkit
- generates a runtime projection type and applies it as a `Select()` before executing the
- query. Only the requested columns are fetched from the database instead of loading full
- entities and filtering in memory.
-- Navigation properties not in `include=` are also excluded from the projection, so EF Core
- skips the corresponding JOINs entirely.
-
-**Configuration (opt-in, disabled by default):**
-```csharp
-services.AddJsonApiToolkit(options =>
-{
- options.EnableDatabaseProjection = true;
-});
-```
-
-**Breaking Changes:** None. The feature is disabled by default and has no effect unless opted in.
-
-**Limitations:**
-- Not compatible with NativeAOT compilation (uses `Reflection.Emit`).
-- Nested include projections (projecting related entities to fewer columns) are not supported;
- included entities are still fully loaded.
-- Falls back to full entity load automatically if projection fails for any reason.
-
----
-
-## v1.7.0 - Sparse Fieldsets
-
-**Release Date:** February 2026
-
-**New Features:**
-- [x] `fields[type]` query parameter support (JSON:API sparse fieldsets)
-- [x] Reduces response payload size by returning only requested attributes
-- [x] Works with included resources: `fields[author]=name,email`
-
-**Usage:**
-```
-GET /articles?fields[articles]=title,body&include=author&fields[author]=name
-```
-
-**Response:**
-```json
-{
- "data": {
- "type": "articles",
- "id": "1",
- "attributes": {
- "title": "Hello World",
- "body": "..."
- }
- },
- "included": [{
- "type": "author",
- "id": "5",
- "attributes": {
- "name": "John Doe"
- }
- }]
-}
-```
-
-**Breaking Changes:** None
-
-**Migration:** None required
-
----
-
-## Released Versions
-
-### v1.6.0 - Query Builder API
-
-**Release Date:** January 2026
-
-**New Features:**
-- [x] `BuildJsonApiQueryAsync()` method for custom query execution
-- [x] Returns processed `IQueryable` with filters, includes, and sorting applied
-- [x] Pagination is intentionally NOT applied - use for exports, aggregations, projections
-- [x] Optional `includeCount` parameter to skip COUNT query for performance
-
-**Usage:**
-
-```csharp
-[HttpGet("export")]
-public async Task ExportBooks()
-{
- // Get processed query WITHOUT pagination
- var result = await BuildJsonApiQueryAsync(_context.Books, "books");
-
- // Execute however you need
- var books = await result.Query.ToListAsync();
-
- // result.TotalCount has the filtered count
- // result.Parameters has parsed query params
- return Ok(books);
-}
-```
-
-**Use Cases:**
-- CSV/Excel exports - need all matching records
-- Aggregations - GROUP BY after filtering
-- Custom projections - Select specific columns
-- Streaming large datasets
-
-**New Types:**
-- `JsonApiQueryResult` - Result class with `Query`, `Parameters`, and `TotalCount` properties
-
-**Breaking Changes:** None (additive only)
-
-**Migration:** None required
-
-**Documentation:** See [Building Custom Queries](build-query.md)
-
----
-
-### v1.5.0 - Test Coverage
-
-**Release Date:** January 2026
-
-**Changes:**
-- [x] Comprehensive test suite with 570+ tests
-- [x] Handler tests: SortingHandler, PaginationHandler, InclusionMapper, NestedPropertyNavigator
-- [x] Integration tests: JsonApiQueryAsync full pipeline
-- [x] Security tests: DoS protection, query limit enforcement
-- [x] Type conversion tests: All 15+ supported filter types
-- [x] Edge case tests: Circular references, boundary values, error conditions
-- [x] No API changes
-
-**Breaking Changes:** None
-
-**What's Tested:**
-| Category | Tests | Coverage |
-|----------|-------|----------|
-| Filtering | 50+ | All operators, nested properties, type conversions |
-| Sorting | 20+ | Multi-field, invalid fields, direction |
-| Pagination | 30+ | Boundary values (0, -1, MAX_INT), clamping |
-| Includes | 40+ | Nested, circular references, deduplication |
-| Security | 26 | DoS limits, bypass attempts, stress tests |
-| Integration | 40+ | Full HTTP pipeline, combined operations |
-
----
-
-### v1.4.0 - Security Hardening
-
-**Release Date:** January 2026
-
-**New Features:**
-- [x] `JsonApiOptions` configuration class for query limits
-- [x] Configurable query complexity limits (filters, groups, depth, page size)
-- [x] AllowedIncludes now validates filter paths (not just includes)
-- [x] Recursion depth guard for nested filter groups
-
-**Configuration:**
-```csharp
-services.AddJsonApiToolkit(options => {
- options.MaxFilters = 50; // Default: 50
- options.MaxFilterGroups = 10; // Default: 10
- options.MaxFilterDepth = 3; // Default: 3
- options.MaxFilterValueLength = 1000; // Default: 1000
- options.MaxIncludeDepth = 3; // Default: 3
- options.MaxPageSize = 100; // Default: 100
- options.DefaultPageSize = 10; // Default: 10
-});
-```
-
-**Breaking Changes:**
-
-#### 1. Query Complexity Limits Enforced
-
-Queries exceeding limits now return 400 Bad Request:
-
-```json
-{
- "errors": [{
- "status": "400",
- "title": "Bad Request",
- "detail": "Query exceeds maximum filter count of 50"
- }]
-}
-```
-
-**Migration:** If your application uses complex queries, increase limits via configuration.
-
-#### 2. Filter Path Validation with AllowedIncludes
-
-Dot-notation filters are now validated against `AllowedIncludes`:
-
-**Before (v1.3):**
-```csharp
-[AllowedIncludes("profile")]
-public async Task GetUsers()
-{
- // filter[admin.password][like]=% would work (security hole!)
-}
-```
-
-**After (v1.4):**
-```csharp
-[AllowedIncludes("profile")]
-public async Task GetUsers()
-{
- // filter[admin.password][like]=% returns 403 Forbidden
-}
-```
-
-**Migration:** Add relationships to `AllowedIncludes` if you need to filter on them.
-
----
-
-### v1.3.0 - Bug Fixes & Error Improvements
-
-**Release Date:** TBD
-
-**Bug Fixes:**
-- [x] Fixed exception swallowing in InclusionMapper (dead code removed)
-- [x] Fixed unsafe string parsing in filter parser
-- [x] Fixed potential division by zero in pagination
-- [x] Added defensive checks for reflection method lookups
-- [x] Removed dead code (`AddIncludedResourcesRecursive`)
-
-**New Features:**
-- [x] `JsonApiErrorCodes` - Standard error codes for consistent error identification
-- [x] `JsonApiErrors` - Factory methods for creating rich, well-structured errors
-
-**Usage:**
-```csharp
-// Before - verbose, missing metadata
-throw new JsonApiNotFoundException("Book not found");
-
-// After - concise, consistent, includes metadata
-throw JsonApiErrors.NotFound("books", id);
-```
-
-Produces:
-```json
-{
- "errors": [{
- "status": "404",
- "code": "RESOURCE_NOT_FOUND",
- "title": "Not Found",
- "detail": "Resource 'books' with id '123' not found",
- "meta": {
- "resourceType": "books",
- "id": "123"
- }
- }]
-}
-```
-
-**Available Factories:**
-| Factory | Status | Use Case |
-|---------|--------|----------|
-| `JsonApiErrors.NotFound(type, id)` | 404 | Resource not found |
-| `JsonApiErrors.InvalidFilterValue(field, value, type)` | 400 | Type conversion failed |
-| `JsonApiErrors.InvalidFilterField(field, entityType)` | 400 | Field doesn't exist |
-| `JsonApiErrors.IncludeNotAllowed(include)` | 403 | Include blocked by AllowedIncludes |
-| `JsonApiErrors.AlreadyExists(type, field, value)` | 409 | Duplicate key violation |
-| `JsonApiErrors.ValidationFailed(field, message)` | 400 | Generic validation error |
-
-**Breaking Changes:** None
-
-**Migration:** None required (existing exception classes still work)
-
----
-
-### v1.2.5
-
-Previous stable version before refactoring began.
-
----
-
-## FAQ
-
-### Q: Will sparse fieldsets slow down my API?
-
-In v1.6, sparse fieldsets only filter at serialization time - the database still loads all columns. In v1.7 with projection enabled, the database query itself is optimized.
-
-### Q: How do I know if my queries exceed the new limits?
-
-Enable debug logging for `JsonApiToolkit` to see query complexity metrics:
-
-```json
-{
- "Logging": {
- "LogLevel": {
- "JsonApiToolkit": "Debug"
- }
- }
-}
-```
-
-### Q: Can I disable the new security limits?
-
-Yes, set them to high values:
-
-```csharp
-services.AddJsonApiToolkit(options => {
- options.MaxFilters = int.MaxValue;
- options.MaxFilterDepth = int.MaxValue;
- // Not recommended for production!
-});
-```
diff --git a/mkdocs.yaml b/mkdocs.yaml
index 93513b0..e0b73d1 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -1,6 +1,6 @@
site_name: Intility JsonApiToolkit
site_description: Documentation for Intility JsonApiToolkit
-site_url: https://intility.github.io/Intility.JsonApiToolkit/
+site_url: https://intility.github.io/json-api-toolkit/
extra:
version: 2.1.0 # x-release-please-version