From dab694288d8f7aab468f3f835881f56740ce8829 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Tue, 25 Aug 2026 10:43:44 +0200 Subject: [PATCH 1/2] feat: unify fields[] type naming via [JsonApiResource] (opt-in) Included resources' "type" (and relationship linkage pointing at them) used the camelCased CLR class name, while the primary resource used the controller-supplied string, so fields[type] could not target both with one name for types like [JsonApiResource("people")] on class Author. New opt-in flag JsonApiOptions.UseResourceAttributeTypeNames makes included resources use their [JsonApiResource] type name when present, falling back to the camelCased class name otherwise. Wire-affecting, so off by default. --- .../Mapping/InclusionMapperTests.cs | 74 +++++++++++++++++++ .../Configuration/JsonApiOptions.cs | 11 +++ .../Controllers/JsonApiController.cs | 15 ++-- JsonApiToolkit/Mapping/EntityMapper.cs | 20 +++-- JsonApiToolkit/Mapping/InclusionMapper.cs | 36 ++++++--- JsonApiToolkit/Mapping/JsonApiMapper.cs | 41 +++++++--- 6 files changed, 168 insertions(+), 29 deletions(-) diff --git a/JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs b/JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs index 1acb9f7..aaf4fb7 100644 --- a/JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs +++ b/JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs @@ -1,3 +1,4 @@ +using JsonApiToolkit.Attributes; using JsonApiToolkit.Mapping; using JsonApiToolkit.Models.Resources; @@ -7,6 +8,7 @@ public class InclusionMapperTests { #region Test Models + [JsonApiResource("people")] private class Author { public int Id { get; set; } @@ -734,4 +736,76 @@ public void AddIncludedResources_SelfReferencingEntity_HandlesCorrectly() } #endregion + + #region Attribute Type Names + + [Fact] + public void AddIncludedResources_UseAttributeTypeNameFalse_UsesCamelCasedClassName() + { + var author = new Author { Id = 1, Name = "John" }; + var book = new Book + { + Id = 100, + Title = "Test Book", + Author = author, + }; + + var included = new List(); + var includePaths = new List { "author" }; + + InclusionMapper.AddIncludedResources(book, includePaths, included); + + Assert.Equal("author", included[0].Type); + } + + [Fact] + public void AddIncludedResources_UseAttributeTypeNameTrue_UsesJsonApiResourceTypeName() + { + var author = new Author { Id = 1, Name = "John" }; + var book = new Book + { + Id = 100, + Title = "Test Book", + Author = author, + }; + + var included = new List(); + var includePaths = new List { "author" }; + + InclusionMapper.AddIncludedResources( + book, + includePaths, + included, + useAttributeTypeName: true + ); + + Assert.Equal("people", included[0].Type); + } + + [Fact] + public void AddIncludedResources_UseAttributeTypeNameTrue_FallsBackWithoutAttribute() + { + // Chapter has no [JsonApiResource], so it still falls back to the class name. + var chapter = new Chapter { Id = 10, Title = "Chapter 1" }; + var book = new Book + { + Id = 100, + Title = "Test Book", + Chapters = new List { chapter }, + }; + + var included = new List(); + var includePaths = new List { "chapters" }; + + InclusionMapper.AddIncludedResources( + book, + includePaths, + included, + useAttributeTypeName: true + ); + + Assert.Equal("chapter", included[0].Type); + } + + #endregion } diff --git a/JsonApiToolkit/Configuration/JsonApiOptions.cs b/JsonApiToolkit/Configuration/JsonApiOptions.cs index c0d231a..8af245f 100644 --- a/JsonApiToolkit/Configuration/JsonApiOptions.cs +++ b/JsonApiToolkit/Configuration/JsonApiOptions.cs @@ -78,4 +78,15 @@ public class JsonApiOptions /// Not compatible with NativeAOT compilation. Default: false. /// public bool EnableDatabaseProjection { get; set; } = false; + + /// + /// When true, an included resource's "type" (and any relationship linkage pointing + /// at it) uses the wire name declared by its [JsonApiResource] attribute + /// instead of the camelCased CLR class name. Types without the attribute keep + /// falling back to the camelCased class name. Fixes the asymmetry where the + /// primary resource uses the controller-supplied type string but included + /// resources use the class name, so fields[type] can target both consistently. + /// Default: false (camelCased class name, for backwards compatibility). + /// + public bool UseResourceAttributeTypeNames { get; set; } } diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 339a5d3..b3901e1 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -93,7 +93,8 @@ protected IActionResult JsonApiOk(T entity, string resourceType) baseUrl, mappedIncludes, Logger, - parameters.Fields + parameters.Fields, + Options.UseResourceAttributeTypeNames ); return Ok(document); } @@ -179,7 +180,8 @@ protected IActionResult JsonApiOk( mappedIncludes, Logger, parameters.Fields, - Options.PreserveQueryInPaginationLinks + Options.PreserveQueryInPaginationLinks, + Options.UseResourceAttributeTypeNames ); return Ok(document); } @@ -256,7 +258,8 @@ string resourceType mappedIncludes, Logger, parameters.Fields, - Options.PreserveQueryInPaginationLinks + Options.PreserveQueryInPaginationLinks, + Options.UseResourceAttributeTypeNames ); return Ok(document); @@ -349,7 +352,8 @@ protected IActionResult JsonApiCreated(T entity, string resourceType, string selfUrl, mappedIncludes, Logger, - parameters.Fields + parameters.Fields, + Options.UseResourceAttributeTypeNames ); return Created(selfUrl, document); } @@ -592,7 +596,8 @@ QueryParameters parameters mappedIncludes, Logger, parameters.Fields, - Options.PreserveQueryInPaginationLinks + Options.PreserveQueryInPaginationLinks, + Options.UseResourceAttributeTypeNames ); return Ok(projectedDocument); diff --git a/JsonApiToolkit/Mapping/EntityMapper.cs b/JsonApiToolkit/Mapping/EntityMapper.cs index abda718..d6e5f21 100644 --- a/JsonApiToolkit/Mapping/EntityMapper.cs +++ b/JsonApiToolkit/Mapping/EntityMapper.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Reflection; using System.Text.Json.Serialization; +using JsonApiToolkit.Attributes; using JsonApiToolkit.Extensions; using JsonApiToolkit.Extensions.Querying; @@ -107,13 +108,22 @@ private static bool IsSingleObjectRelationship(PropertyInfo p) => && HasIdProperty(p.PropertyType); /// - /// Gets the JSON:API resource type name (entity class name in camelCase). - /// Example: "Person" becomes "person". + /// Gets the JSON:API resource type name. + /// Default: entity class name in camelCase (e.g. "Person" becomes "person"). + /// When is true and the type carries + /// , its declared wire type is used instead. /// - public static string GetResourceType(Type type) + public static string GetResourceType(Type type, bool useAttributeTypeName = false) { - string name = type.Name; - return name.ToCamelCase(); + if (useAttributeTypeName) + { + string? attributeTypeName = + type.GetCustomAttribute()?.TypeName; + if (attributeTypeName != null) + return attributeTypeName; + } + + return type.Name.ToCamelCase(); } /// diff --git a/JsonApiToolkit/Mapping/InclusionMapper.cs b/JsonApiToolkit/Mapping/InclusionMapper.cs index 470ce33..45f68bf 100644 --- a/JsonApiToolkit/Mapping/InclusionMapper.cs +++ b/JsonApiToolkit/Mapping/InclusionMapper.cs @@ -23,7 +23,8 @@ public static void AddIncludedResources( List included, ILogger? logger = null, HashSet? processedEntities = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool useAttributeTypeName = false ) { if (entityOrCollection == null || includePaths == null || includePaths.Count == 0) @@ -51,7 +52,8 @@ public static void AddIncludedResources( included, processedEntities, logger, - fields + fields, + useAttributeTypeName ); } } @@ -64,7 +66,8 @@ public static void AddIncludedResources( included, processedEntities, logger, - fields + fields, + useAttributeTypeName ); } } @@ -77,7 +80,8 @@ private static void AddIncludedForEntity( List included, HashSet processedEntities, ILogger? logger = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool useAttributeTypeName = false ) { if (entity == null) @@ -110,13 +114,22 @@ private static void AddIncludedForEntity( processedEntities, nestedPaths, logger, - fields + fields, + useAttributeTypeName ); } } else { - AddSingleIncluded(relValue, included, processedEntities, nestedPaths, logger, fields); + AddSingleIncluded( + relValue, + included, + processedEntities, + nestedPaths, + logger, + fields, + useAttributeTypeName + ); } } @@ -126,7 +139,8 @@ private static void AddSingleIncluded( HashSet processedEntities, List nestedPaths, ILogger? logger = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool useAttributeTypeName = false ) { if (relEntity == null) @@ -145,7 +159,7 @@ private static void AddSingleIncluded( return; string id = idValue.ToString()!; - string resourceType = EntityMapper.GetResourceType(type); + string resourceType = EntityMapper.GetResourceType(type, useAttributeTypeName); string key = $"{resourceType}:{id}"; if (!processedEntities.Add(key)) @@ -155,7 +169,8 @@ private static void AddSingleIncluded( relEntity, resourceType, nestedPaths, - fields: fields + fields: fields, + useAttributeTypeName: useAttributeTypeName ); included.Add(resourceObject); @@ -167,7 +182,8 @@ private static void AddSingleIncluded( included, logger, processedEntities, - fields + fields, + useAttributeTypeName ); } } diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs index cb938e8..5691753 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -23,7 +23,8 @@ public static ResourceObject ToResourceObject( string resourceType, List? includedRelationships = null, ILogger? logger = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool useAttributeTypeName = false ) { ArgumentNullException.ThrowIfNull(entity); @@ -123,7 +124,10 @@ public static ResourceObject ToResourceObject( new ResourceIdentifier { Id = itemId, - Type = EntityMapper.GetResourceType(itemType), + Type = EntityMapper.GetResourceType( + itemType, + useAttributeTypeName + ), } ); } @@ -141,7 +145,10 @@ public static ResourceObject ToResourceObject( relationship.Data = new ResourceIdentifier { Id = relItemId, - Type = EntityMapper.GetResourceType(relItemType), + Type = EntityMapper.GetResourceType( + relItemType, + useAttributeTypeName + ), }; } } @@ -168,6 +175,11 @@ public static ResourceObject ToResourceObject( /// Optional list of relationship paths to include /// Optional logger for debugging and tracing /// Optional sparse fieldsets per resource type + /// + /// When true, included resources' "type" comes from their [JsonApiResource] + /// attribute instead of the camelCased CLR class name (see + /// ). + /// /// A fully populated JSON:API document representing the entity /// /// @@ -192,7 +204,8 @@ public static JsonApiDocument ToDocument( string selfLink, List? includedRelationships = null, ILogger? logger = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool useAttributeTypeName = false ) where T : class { @@ -207,7 +220,8 @@ public static JsonApiDocument ToDocument( resourceType, includedRelationships, logger, - fields + fields, + useAttributeTypeName ); resource.Links = new Links { Self = selfLink }; @@ -230,7 +244,8 @@ public static JsonApiDocument ToDocument( includedRelationships, included, logger, - fields: fields + fields: fields, + useAttributeTypeName: useAttributeTypeName ); logger?.LogDebug( @@ -266,6 +281,11 @@ public static JsonApiDocument ToDocument( /// Optional logger for debugging and tracing /// Optional sparse fieldsets per resource type /// When true, pagination links keep the full query string with only the page parameters replaced + /// + /// When true, included resources' "type" comes from their [JsonApiResource] + /// attribute instead of the camelCased CLR class name (see + /// ). + /// /// The JSON:API collection document. public static JsonApiCollectionDocument ToCollectionDocument( IEnumerable entities, @@ -275,7 +295,8 @@ public static JsonApiCollectionDocument ToCollectionDocument( List? includedRelationships = null, ILogger? logger = null, Dictionary>? fields = null, - bool preserveQueryInLinks = false + bool preserveQueryInLinks = false, + bool useAttributeTypeName = false ) where T : class { @@ -295,7 +316,8 @@ public static JsonApiCollectionDocument ToCollectionDocument( resourceType, includedRelationships, logger, - fields + fields, + useAttributeTypeName ); resource.Links = new Links { Self = $"{baseUrl}/{resource.Id}" }; return resource; @@ -354,7 +376,8 @@ string PageLink(int pageNumber) => includedRelationships, included, logger, - fields: fields + fields: fields, + useAttributeTypeName: useAttributeTypeName ); logger?.LogDebug( From 604fbf072b87c87ccb1a86c02a3126fe6203db02 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Tue, 25 Aug 2026 10:53:08 +0200 Subject: [PATCH 2/2] docs: document UseResourceAttributeTypeNames --- docs/querying.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/querying.md b/docs/querying.md index 554f5ca..03c6747 100644 --- a/docs/querying.md +++ b/docs/querying.md @@ -81,6 +81,14 @@ fields[books]=title&fields[author]=name&include=author 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). +By default, an included resource's `type` is the camelCased CLR class name (e.g. `Author` becomes `author`), which may not match the string your controller uses for the primary resource. Types marked with `[JsonApiResource("people")]` can use that declared name instead: + +```csharp +builder.Services.AddJsonApiToolkit(options => options.UseResourceAttributeTypeNames = true); +``` + +Types without the attribute still fall back to the camelCased class name. + ## Putting it together ```