From cd14bcf6d694bef3d17fa2a0099829237fb5092a Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Thu, 7 May 2026 14:07:08 +0200 Subject: [PATCH 1/5] docs: rename repo URLs and restructure README for OSS --- .github/CODEOWNERS | 4 - .github/dependabot.yml | 1 - AGENTS.md | 2 +- .../Attributes/AllowedIncludesAttribute.cs | 2 +- .../Helpers/ReflectionMethodCache.cs | 18 +-- JsonApiToolkit/JsonApiToolkit.csproj | 2 +- README.md | 128 ++++++++---------- docs/contributing.md | 8 +- docs/getting-started.md | 67 +-------- docs/integrations/ts-tools.md | 6 +- mkdocs.yaml | 2 +- 11 files changed, 79 insertions(+), 161 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e3dd949..2706874 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1 @@ -# Default owner for everything * @erlendellefsen - -# Security-sensitive files require extra review -.github/workflows/** @erlendellefsen diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 50eb81b..e6c9efd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,7 +14,6 @@ updates: commit-message: prefix: "build(nuget)" groups: - # Group minor/patch updates to reduce PR noise microsoft: patterns: - "Microsoft.*" diff --git a/AGENTS.md b/AGENTS.md index ec3cb67..a50c679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,5 +17,5 @@ Set `"JsonApiToolkit": "Debug"` in appsettings.json to enable detailed query pro - **Pagination clamping**: Invalid page numbers are silently clamped (negative/zero -> page 1, overflow -> last page). - **Malformed query params**: Bad filter/sort/include syntax is logged and skipped, not thrown as exceptions. - **Filtered includes**: Dot notation in filters (e.g. `filter[author.name]=John`) applies to included resources when `include=author` is also set. -- **Include whitelisting**: `AllowedIncludesAttribute` on controller actions restricts which relationships can be requested via `include=`, preventing unauthorized data exposure. +- **Include allowlisting**: `AllowedIncludesAttribute` on controller actions restricts which relationships can be requested via `include=`, preventing unauthorized data exposure. - **Sparse fieldsets**: `fields[type]=field1,field2` works for both primary and included resources. `id` and `type` are always returned. diff --git a/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs index 45eed12..6bf94b8 100644 --- a/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs +++ b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs @@ -10,7 +10,7 @@ namespace JsonApiToolkit.Attributes; /// /// Restricts which relationships can be included in responses. -/// Returns 403 Forbidden if requested includes don't match the whitelist. +/// Returns 403 Forbidden if requested includes don't match the allowlist. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class AllowedIncludesAttribute : ActionFilterAttribute diff --git a/JsonApiToolkit/Helpers/ReflectionMethodCache.cs b/JsonApiToolkit/Helpers/ReflectionMethodCache.cs index 9e4abec..dcbb63f 100644 --- a/JsonApiToolkit/Helpers/ReflectionMethodCache.cs +++ b/JsonApiToolkit/Helpers/ReflectionMethodCache.cs @@ -50,7 +50,7 @@ internal static MethodInfo GetEnumerableAnyWithPredicate(Type elementType) ?? throw new InvalidOperationException( "Could not find Enumerable.Any(IEnumerable, Func) method. " + "This is a core .NET method that should always exist. " - + "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" ); } } @@ -74,7 +74,7 @@ internal static MethodInfo GetEnumerableContains(Type elementType) ?? throw new InvalidOperationException( "Could not find Enumerable.Contains(IEnumerable, T) method. " + "This is a core .NET method that should always exist. " - + "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" ); } } @@ -98,7 +98,7 @@ internal static MethodInfo GetEnumerableWhere(Type elementType) ?? throw new InvalidOperationException( "Could not find Enumerable.Where(IEnumerable, Func) method. " + "This is a core .NET method that should always exist. " - + "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" ); } } @@ -126,7 +126,7 @@ Type propertyType ?? throw new InvalidOperationException( $"Could not find Queryable.{methodName} method. " + "This is a core .NET method that should always exist. " - + "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" ); return method.MakeGenericMethod(entityType, propertyType); @@ -154,7 +154,7 @@ internal static MethodInfo GetEfCoreIncludeMethod(Type entityType, Type property ?? throw new InvalidOperationException( "Could not find EntityFrameworkQueryableExtensions.Include 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" ); } } @@ -195,7 +195,7 @@ internal static MethodInfo GetQueryableSelectMethod(Type sourceType, Type projec ?? throw new InvalidOperationException( "Could not find Queryable.Select(IQueryable, Expression>) method. " + "This is a core .NET method that should always exist. " - + "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" ); } } @@ -232,7 +232,7 @@ internal static MethodInfo GetEfCoreToListAsyncMethod(Type elementType) ?? throw new InvalidOperationException( "Could not find EntityFrameworkQueryableExtensions.ToListAsync(IQueryable, 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/README.md b/README.md index f4c4f0d..15affdf 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,77 @@ -[![CI/CD Pipeline](https://github.com/intility/Intility.JsonApiToolkit/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/intility/Intility.JsonApiToolkit/actions/workflows/ci-cd.yml) -[![Docs](https://github.com/intility/Intility.JsonApiToolkit/actions/workflows/docs.yml/badge.svg)](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 +

+ +

+ A .NET toolkit for implementing the JSON:API specification. +

+

+ + .NET version + + + NuGet + + + JSON:API version + + + License + + + CI/CD + +

+ +## Description + +JsonApiToolkit makes ASP.NET Core APIs speak the [JSON:API specification](https://jsonapi.org/). It translates standard query parameters (`filter[]`, `sort`, `include`, `fields[]`, `page[]`) into typed EF Core queries and returns spec-compliant response documents, so your controllers stay short and predictable. ## 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 +{ + [HttpGet] + [AllowedIncludes("author", "publisher")] + public Task GetBooks() => + JsonApiQueryAsync(_dbContext.Books.AsQueryable(), "book"); +} +``` -## 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/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/getting-started.md b/docs/getting-started.md index 51abf43..8c67671 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 ``` @@ -72,51 +53,5 @@ dotnet add package Intility.JsonApiToolkit 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! - - -## GitHub Actions -To get fetch the package in your GitHub Actions workflow, add the following to your workflow file: - -```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 -``` - -> [!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" -``` +> Now your API is ready to return responses that fully comply with the JSON:API specification! -> [!IMPORTANT] -> You need to create a token with `read:packages` that you add to -> your dependabot secrets. diff --git a/docs/integrations/ts-tools.md b/docs/integrations/ts-tools.md index 7201401..3d743a4 100644 --- a/docs/integrations/ts-tools.md +++ b/docs/integrations/ts-tools.md @@ -4,7 +4,7 @@ **jsonapi-ts-tools** is a lightweight, Deno-based TypeScript library designed to make working with -[JsonApiToolkit](https://github.com/intility/Intility.JsonApiToolkit) responses +[JsonApiToolkit](https://github.com/intility/json-api-toolkit) responses in TypeScript applications easier. ## Features @@ -15,11 +15,11 @@ in TypeScript applications easier. 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. +- **JsonApiToolkit**: This library is designed to work with [JsonApiToolkit](https://github.com/intility/json-api-toolkit). 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. +You can read more about jsonapi-ts-tools & JsonApiToolkit [**here**](https://intility.github.io/json-api-toolkit/docs/integrations/ts-tools.html), or follow the instructions below for a quick start. ### Installation 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 From c13e5eb87c6aa310450bed95e07bd50740332d20 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 08:09:16 +0200 Subject: [PATCH 2/5] docs: prep repo and docs for OSS release --- JsonApiToolkit/packages.lock.json | 12 + README.md | 12 +- SECURITY.md | 14 + docs/.pages | 12 +- docs/api-controller-examples.md | 249 ---------- docs/build-query.md | 435 +++++------------- docs/debugging.md | 49 -- docs/enhanced-error-handling.md | 227 --------- docs/error-handling.md | 94 ++++ docs/getting-started.md | 54 ++- docs/index.md | 53 ++- docs/integrations/.pages | 4 - docs/integrations/ts-tools.md | 199 -------- docs/introduction.md | 19 - docs/{integrations/open-api.md => openapi.md} | 0 docs/querying.md | 164 +++---- docs/recipes.md | 135 ++++++ docs/security.md | 240 ++-------- docs/troubleshooting.md | 57 +++ docs/upgrade-guide.md | 368 --------------- 20 files changed, 646 insertions(+), 1751 deletions(-) create mode 100644 SECURITY.md delete mode 100644 docs/api-controller-examples.md delete mode 100644 docs/debugging.md delete mode 100644 docs/enhanced-error-handling.md create mode 100644 docs/error-handling.md delete mode 100644 docs/integrations/.pages delete mode 100644 docs/integrations/ts-tools.md delete mode 100644 docs/introduction.md rename docs/{integrations/open-api.md => openapi.md} (100%) create mode 100644 docs/recipes.md create mode 100644 docs/troubleshooting.md delete mode 100644 docs/upgrade-guide.md 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 15affdf..6ab503a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- A .NET toolkit for implementing the JSON:API specification. + Build JSON:API endpoints in ASP.NET Core.

@@ -26,7 +26,7 @@ ## Description -JsonApiToolkit makes ASP.NET Core APIs speak the [JSON:API specification](https://jsonapi.org/). It translates standard query parameters (`filter[]`, `sort`, `include`, `fields[]`, `page[]`) into typed EF Core queries and returns spec-compliant response documents, so your controllers stay short and predictable. +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 @@ -47,10 +47,14 @@ Derive controllers from `JsonApiController` and let `JsonApiQueryAsync` handle t ```csharp public class BooksController : JsonApiController { + private const string ResourceType = "book"; + [HttpGet] [AllowedIncludes("author", "publisher")] - public Task GetBooks() => - JsonApiQueryAsync(_dbContext.Books.AsQueryable(), "book"); + public async Task GetAllAsync() + { + return await JsonApiQueryAsync(_dbContext.Books, ResourceType); + } } ``` 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/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 8c67671..363882c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,19 +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. +3. **Configuration is automatic:** + `AddJsonApiToolkit()` registers JSON serialization settings (camelCase, ignore nulls) and the `application/vnd.api+json` media type. + +## Example + +With the controller above, this request: + +``` +GET /api/books?filter[title][like]=Hobbit&include=author&page[size]=5&sort=-publishedDate +``` + +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": "..." } +} +``` -> [!NOTE] -> Now your API is ready to return responses that fully comply with the JSON:API specification! +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 3d743a4..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/json-api-toolkit) 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/json-api-toolkit). 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/json-api-toolkit/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 ?

: ( -
    - {data.map((todo) => ( -
  • -

    {todo.title}

    -

    Due date: {todo.dueDate}

    -

    Owner: {todo.owner.name}

    -

    Tags: {todo.tags.map((tag) => tag.label).join(', ')}

    -
  • - ))} -
-)} -``` - -### 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! -}); -``` From bfe43904a6169147654e590856d9e5c067c10c2a Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 08:36:46 +0200 Subject: [PATCH 3/5] chore: tighten issue templates and add PR template --- .github/ISSUE_TEMPLATE/1-basic-issue.yml | 4 -- .github/ISSUE_TEMPLATE/2-feature_request.yml | 22 +--------- .github/ISSUE_TEMPLATE/3-bug-report.yml | 42 +++++++++++--------- .github/pull_request_template.md | 7 ++++ .github/workflows/release-please.yml | 2 +- 5 files changed, 34 insertions(+), 43 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/1-basic-issue.yml b/.github/ISSUE_TEMPLATE/1-basic-issue.yml index 636f7b2..2d5a664 100644 --- a/.github/ISSUE_TEMPLATE/1-basic-issue.yml +++ b/.github/ISSUE_TEMPLATE/1-basic-issue.yml @@ -3,10 +3,6 @@ description: File a basic issue. type: Task projects: ['intility/56'] body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this issue! - type: textarea attributes: label: What's the issue? diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.yml b/.github/ISSUE_TEMPLATE/2-feature_request.yml index b373402..2b9f08d 100644 --- a/.github/ISSUE_TEMPLATE/2-feature_request.yml +++ b/.github/ISSUE_TEMPLATE/2-feature_request.yml @@ -4,35 +4,17 @@ labels: ['enhancement'] type: Feature projects: ['intility/56'] body: - - type: markdown - attributes: - value: | - Thanks for taking the time to request a new feature or enhancement. Please fill out the form below to help us understand your request. - type: textarea id: feature-request attributes: label: Feature request - description: Please describe the feature you would like to see. - placeholder: I would like to see... + description: What would you like to see? validations: required: true - type: textarea id: use-case attributes: label: Use case - description: Please describe the use case for this feature. - placeholder: This feature would be useful for... - validations: - required: false - - type: dropdown - id: priority - attributes: - label: Priority - description: Please select the priority of this feature request. - options: - - Low - - Medium - - High - default: 2 + description: What problem does this solve? Why is the current behavior insufficient? validations: required: true diff --git a/.github/ISSUE_TEMPLATE/3-bug-report.yml b/.github/ISSUE_TEMPLATE/3-bug-report.yml index a045afc..b82b4ef 100644 --- a/.github/ISSUE_TEMPLATE/3-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/3-bug-report.yml @@ -4,32 +4,38 @@ labels: ['bug'] type: Bug projects: ['intility/56'] body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! - type: textarea id: what-happened attributes: label: What happened? - description: Also tell us, what did you expect to happen? - placeholder: Tell us what you see! - value: 'A bug happened!' + description: What did you observe, and what did you expect instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Minimal repro steps. Code snippet, request, or test case if possible. + render: csharp validations: required: true - - type: dropdown - id: browsers + - type: input + id: toolkit-version attributes: - label: What browsers are you seeing the problem on? - multiple: true - options: - - Firefox - - Chrome - - Safari - - Microsoft Edge + label: JsonApiToolkit version + placeholder: e.g. 2.1.0 + validations: + required: true + - type: input + id: dotnet-version + attributes: + label: .NET version + placeholder: e.g. 10.0 + validations: + required: true - type: textarea id: logs attributes: - label: Relevant log output - description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + label: Relevant logs + description: Optional. Paste relevant log output. Formatting is automatic. render: shell diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2705b2b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Summary + + + +## Related issues + + diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 62c8c79..0fe5c3d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 id: app-token with: - app-id: ${{ vars.RELEASE_BOT_APP_ID }} + client-id: ${{ vars.RELEASE_BOT_APP_ID }} private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 From 5af9defc0178bcd7b3decf69873721160f4e5023 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 08:51:20 +0200 Subject: [PATCH 4/5] chore(codeql): resolve open alerts --- .github/codeql/codeql-config.yml | 5 +++++ .github/workflows/codeql.yml | 1 + .../Projection/ProjectionPropertySelector.cs | 10 +++++----- JsonApiToolkit/Helpers/ReflectionMethodCache.cs | 4 ++-- JsonApiToolkit/packages.lock.json | 12 ------------ 5 files changed, 13 insertions(+), 19 deletions(-) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..14a2a71 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,5 @@ +name: "JsonApiToolkit CodeQL" + +paths-ignore: + - "**/bin/**" + - "**/obj/**" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6fec18c..f1ecc4d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,6 +32,7 @@ jobs: with: languages: csharp queries: security-and-quality + config-file: ./.github/codeql/codeql-config.yml - name: Restore run: dotnet restore --locked-mode diff --git a/JsonApiToolkit/Extensions/Projection/ProjectionPropertySelector.cs b/JsonApiToolkit/Extensions/Projection/ProjectionPropertySelector.cs index 008e2f3..4cf7453 100644 --- a/JsonApiToolkit/Extensions/Projection/ProjectionPropertySelector.cs +++ b/JsonApiToolkit/Extensions/Projection/ProjectionPropertySelector.cs @@ -34,11 +34,11 @@ List requestedFieldsCamelCase requestedFieldsCamelCase, StringComparer.OrdinalIgnoreCase ); - foreach (PropertyInfo prop in EntityMapper.GetAttributeProperties(sourceType)) - { - if (fieldSet.Contains(EntityMapper.GetAttributeName(prop))) - result.Add(prop); - } + result.AddRange( + EntityMapper + .GetAttributeProperties(sourceType) + .Where(prop => fieldSet.Contains(EntityMapper.GetAttributeName(prop))) + ); return result; } diff --git a/JsonApiToolkit/Helpers/ReflectionMethodCache.cs b/JsonApiToolkit/Helpers/ReflectionMethodCache.cs index dcbb63f..0ddaeeb 100644 --- a/JsonApiToolkit/Helpers/ReflectionMethodCache.cs +++ b/JsonApiToolkit/Helpers/ReflectionMethodCache.cs @@ -15,8 +15,8 @@ internal static class ReflectionMethodCache private static MethodInfo? s_enumerableContains; private static MethodInfo? s_enumerableWhere; private static MethodInfo? s_efCoreIncludeExpression; - private static MethodInfo? s_thenIncludeCollection; - private static MethodInfo? s_thenIncludeReference; + private static volatile MethodInfo? s_thenIncludeCollection; + private static volatile MethodInfo? s_thenIncludeReference; private static MethodInfo? s_queryableSelect; private static readonly ConcurrentDictionary< (Type Source, Type Projection), diff --git a/JsonApiToolkit/packages.lock.json b/JsonApiToolkit/packages.lock.json index 68df6a2..2d7c1e4 100644 --- a/JsonApiToolkit/packages.lock.json +++ b/JsonApiToolkit/packages.lock.json @@ -413,18 +413,6 @@ "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 From 702ff6ced4c5e692c7716296e26898dea60e7203 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 08:53:20 +0200 Subject: [PATCH 5/5] ci: add workflow_dispatch to codeql --- .github/workflows/codeql.yml | 3 ++- JsonApiToolkit/packages.lock.json | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f1ecc4d..90c7c79 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,8 @@ name: CodeQL on: schedule: - - cron: "0 6 * * 1" # Monday 6am UTC + - cron: "0 6 * * 1" + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} 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