Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using JsonApiToolkit.Attributes;
using JsonApiToolkit.Mapping;
using JsonApiToolkit.Models.Resources;

Expand All @@ -7,6 +8,7 @@ public class InclusionMapperTests
{
#region Test Models

[JsonApiResource("people")]
private class Author
{
public int Id { get; set; }
Expand Down Expand Up @@ -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<ResourceObject>();
var includePaths = new List<string> { "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<ResourceObject>();
var includePaths = new List<string> { "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> { chapter },
};

var included = new List<ResourceObject>();
var includePaths = new List<string> { "chapters" };

InclusionMapper.AddIncludedResources(
book,
includePaths,
included,
useAttributeTypeName: true
);

Assert.Equal("chapter", included[0].Type);
}

#endregion
}
11 changes: 11 additions & 0 deletions JsonApiToolkit/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,15 @@ public class JsonApiOptions
/// Not compatible with NativeAOT compilation. Default: false.
/// </summary>
public bool EnableDatabaseProjection { get; set; } = false;

/// <summary>
/// When true, an included resource's "type" (and any relationship linkage pointing
/// at it) uses the wire name declared by its <c>[JsonApiResource]</c> 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).
/// </summary>
public bool UseResourceAttributeTypeNames { get; set; }
}
15 changes: 10 additions & 5 deletions JsonApiToolkit/Controllers/JsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ protected IActionResult JsonApiOk<T>(T entity, string resourceType)
baseUrl,
mappedIncludes,
Logger,
parameters.Fields
parameters.Fields,
Options.UseResourceAttributeTypeNames
);
return Ok(document);
}
Expand Down Expand Up @@ -179,7 +180,8 @@ protected IActionResult JsonApiOk<T>(
mappedIncludes,
Logger,
parameters.Fields,
Options.PreserveQueryInPaginationLinks
Options.PreserveQueryInPaginationLinks,
Options.UseResourceAttributeTypeNames
);
return Ok(document);
}
Expand Down Expand Up @@ -256,7 +258,8 @@ string resourceType
mappedIncludes,
Logger,
parameters.Fields,
Options.PreserveQueryInPaginationLinks
Options.PreserveQueryInPaginationLinks,
Options.UseResourceAttributeTypeNames
);

return Ok(document);
Expand Down Expand Up @@ -349,7 +352,8 @@ protected IActionResult JsonApiCreated<T>(T entity, string resourceType, string
selfUrl,
mappedIncludes,
Logger,
parameters.Fields
parameters.Fields,
Options.UseResourceAttributeTypeNames
);
return Created(selfUrl, document);
}
Expand Down Expand Up @@ -592,7 +596,8 @@ QueryParameters parameters
mappedIncludes,
Logger,
parameters.Fields,
Options.PreserveQueryInPaginationLinks
Options.PreserveQueryInPaginationLinks,
Options.UseResourceAttributeTypeNames
);

return Ok(projectedDocument);
Expand Down
20 changes: 15 additions & 5 deletions JsonApiToolkit/Mapping/EntityMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -107,13 +108,22 @@ private static bool IsSingleObjectRelationship(PropertyInfo p) =>
&& HasIdProperty(p.PropertyType);

/// <summary>
/// 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 <paramref name="useAttributeTypeName"/> is true and the type carries
/// <see cref="JsonApiResourceAttribute"/>, its declared wire type is used instead.
/// </summary>
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<JsonApiResourceAttribute>()?.TypeName;
if (attributeTypeName != null)
return attributeTypeName;
}

return type.Name.ToCamelCase();
}

/// <summary>
Expand Down
36 changes: 26 additions & 10 deletions JsonApiToolkit/Mapping/InclusionMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public static void AddIncludedResources(
List<ResourceObject> included,
ILogger? logger = null,
HashSet<string>? processedEntities = null,
Dictionary<string, List<string>>? fields = null
Dictionary<string, List<string>>? fields = null,
bool useAttributeTypeName = false
)
{
if (entityOrCollection == null || includePaths == null || includePaths.Count == 0)
Expand Down Expand Up @@ -51,7 +52,8 @@ public static void AddIncludedResources(
included,
processedEntities,
logger,
fields
fields,
useAttributeTypeName
);
}
}
Expand All @@ -64,7 +66,8 @@ public static void AddIncludedResources(
included,
processedEntities,
logger,
fields
fields,
useAttributeTypeName
);
}
}
Expand All @@ -77,7 +80,8 @@ private static void AddIncludedForEntity(
List<ResourceObject> included,
HashSet<string> processedEntities,
ILogger? logger = null,
Dictionary<string, List<string>>? fields = null
Dictionary<string, List<string>>? fields = null,
bool useAttributeTypeName = false
)
{
if (entity == null)
Expand Down Expand Up @@ -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
);
}
}

Expand All @@ -126,7 +139,8 @@ private static void AddSingleIncluded(
HashSet<string> processedEntities,
List<string> nestedPaths,
ILogger? logger = null,
Dictionary<string, List<string>>? fields = null
Dictionary<string, List<string>>? fields = null,
bool useAttributeTypeName = false
)
{
if (relEntity == null)
Expand All @@ -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))
Expand All @@ -155,7 +169,8 @@ private static void AddSingleIncluded(
relEntity,
resourceType,
nestedPaths,
fields: fields
fields: fields,
useAttributeTypeName: useAttributeTypeName
);
included.Add(resourceObject);

Expand All @@ -167,7 +182,8 @@ private static void AddSingleIncluded(
included,
logger,
processedEntities,
fields
fields,
useAttributeTypeName
);
}
}
Expand Down
Loading