From 770b10ec6350aa7572479620a2a3de21d53cfa3c Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Wed, 3 Dec 2025 12:45:20 -0600 Subject: [PATCH 01/12] Add generic subclasses. --- Crews.Web.JsonApiClient/JsonApiDocument.cs | 56 +++++++++---------- .../JsonApiRelationship.cs | 28 ++++++++++ Crews.Web.JsonApiClient/JsonApiResource.cs | 13 +++++ 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index 9589b46..b972259 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -75,38 +75,32 @@ public class JsonApiDocument /// Gets a value indicating whether the property contains one or more objects. /// public bool HasErrors => Errors is not null && Errors.Any(); +} - /// - /// Attempts to deserialize the property as a object. +/// +/// Represents a JSON:API top-level object with a generic single resource type as defined in section 7.1 of the +/// JSON:API specification. +/// +/// +public class JsonApiDocument : JsonApiDocument where T : JsonApiResource +{ + /// + /// Gets or sets the primary data payload associated with the document. /// - /// - /// The deserialized object if is a valid resource object, or - /// if is . - /// - /// - public JsonApiResource? GetResource() - { - if (Data is null) return null; - if (Data is JsonElement data && data.ValueKind == JsonValueKind.Object) - return data.Deserialize(); - - throw new InvalidOperationException(Constants.Exceptions.GetResourceInvalidType); - } + [JsonPropertyName("data")] + public new T? Data { get; set; } +} - /// - /// Attempts to deserialize the property as a collection of objects. +/// +/// Represents a JSON:API top-level object with a generic resource collection type as defined in section 7.1 of the +/// JSON:API specification. +/// +/// +public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerable +{ + /// + /// Gets or sets the primary data payload associated with the document. /// - /// - /// The deserialized collection if is a valid resource array, or - /// if is . - /// - /// - public IEnumerable? GetResourceCollection() - { - if (Data is null) return null; - if (Data is JsonElement data && data.ValueKind == JsonValueKind.Array) - return data.Deserialize(); - - throw new InvalidOperationException(Constants.Exceptions.GetResourceCollectionInvalidType); - } -} + [JsonPropertyName("data")] + public new T? Data { get; set; } +} \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiRelationship.cs b/Crews.Web.JsonApiClient/JsonApiRelationship.cs index 1449adf..98d1243 100644 --- a/Crews.Web.JsonApiClient/JsonApiRelationship.cs +++ b/Crews.Web.JsonApiClient/JsonApiRelationship.cs @@ -33,3 +33,31 @@ public class JsonApiRelationship [JsonExtensionData] public Dictionary? Extensions { get; set; } } + +/// +/// Represents a relationship object with a generic resource identifier type as defined in section 7.2.2.2 of the +/// JSON:API specification. +/// +/// The type of the resource identifier object in the property. +public class JsonApiRelationship : JsonApiRelationship where T : JsonApiResourceIdentifier +{ + /// + /// Gets or sets the data payload associated with the response or request. + /// + public new T? Data { get; set; } +} + +/// +/// Represents a relationship object with a generic resource identifier collection type as defined in section 7.2.2.2 +/// of the JSON:API specification. +/// +/// +/// The type of the resource identifier collection object in the property. +/// +public class JsonApiCollectionRelationship : JsonApiRelationship where T : IEnumerable +{ + /// + /// Gets or sets the data payload associated with the response or request. + /// + public new T? Data { get; set; } +} \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiResource.cs b/Crews.Web.JsonApiClient/JsonApiResource.cs index 8eb31dd..ed689f6 100644 --- a/Crews.Web.JsonApiClient/JsonApiResource.cs +++ b/Crews.Web.JsonApiClient/JsonApiResource.cs @@ -32,3 +32,16 @@ public class JsonApiResource : JsonApiResourceIdentifier [JsonPropertyName("meta")] public JsonObject? Metadata { get; set; } } + +/// +/// Represents a resource object with a generic type as defined in section 7.2 of the JSON:API +/// specification. +/// +/// The type of the property. +public class JsonApiResource : JsonApiResource +{ + /// + /// Gets or sets the collection of custom attributes associated with this object. + /// + public new T? Attributes { get; set; } +} \ No newline at end of file From 1d31da91322896a6b6cca3bcb907fc6e4dab2d2b Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Wed, 3 Dec 2025 19:23:06 -0600 Subject: [PATCH 02/12] Refine and document new methods and classes. --- Crews.Web.JsonApiClient/JsonApiDocument.cs | 65 +++++++++++++++---- .../JsonApiRelationship.cs | 24 +++---- Crews.Web.JsonApiClient/JsonApiResource.cs | 32 ++++++--- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index b972259..d395594 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -75,32 +75,73 @@ public class JsonApiDocument /// Gets a value indicating whether the property contains one or more objects. /// public bool HasErrors => Errors is not null && Errors.Any(); + + /// + /// Deserializes the specified JSON string into a instance. + /// + /// This method uses for deserialization. The input + /// JSON must conform to the JSON:API specification for successful parsing. + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization behavior. + /// + /// A instance representing the deserialized data, or if the + /// input is invalid or does not match the expected format. + /// + public static JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize(json, options); } -/// -/// Represents a JSON:API top-level object with a generic single resource type as defined in section 7.1 of the -/// JSON:API specification. -/// +/// +/// Represents a JSON:API top-level object with a generic single resource type as defined in section 7.1 of the +/// JSON:API specification. +/// /// public class JsonApiDocument : JsonApiDocument where T : JsonApiResource { - /// - /// Gets or sets the primary data payload associated with the document. + /// + /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] public new T? Data { get; set; } + + /// + /// Deserializes the specified JSON string into a strongly typed instance. + /// + /// This method uses for deserialization. The + /// generic type parameter must match the expected resource type in the JSON:API + /// document. + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization process. + /// + /// A instance representing the deserialized JSON:API document, or if the input is null or invalid. + /// + public static new JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize>(json, options); } -/// -/// Represents a JSON:API top-level object with a generic resource collection type as defined in section 7.1 of the -/// JSON:API specification. -/// +/// +/// Represents a JSON:API top-level object with a generic resource collection type as defined in section 7.1 of the +/// JSON:API specification. +/// /// public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerable { - /// - /// Gets or sets the primary data payload associated with the document. + /// + /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] public new T? Data { get; set; } + + /// + /// Deserializes the specified JSON string into a instance. + /// + /// The JSON string representing a collection document to deserialize. + /// Options to control the behavior of the deserialization. + /// + /// A instance representing the deserialized data, or if the input is null or empty. + /// + public static new JsonApiCollectionDocument? Deserialize(string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize>(json, options); } \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiRelationship.cs b/Crews.Web.JsonApiClient/JsonApiRelationship.cs index 98d1243..5b15ee2 100644 --- a/Crews.Web.JsonApiClient/JsonApiRelationship.cs +++ b/Crews.Web.JsonApiClient/JsonApiRelationship.cs @@ -35,29 +35,29 @@ public class JsonApiRelationship } /// -/// Represents a relationship object with a generic resource identifier type as defined in section 7.2.2.2 of the -/// JSON:API specification. -/// +/// Represents a relationship object with a generic resource identifier type as defined in section 7.2.2.2 of the +/// JSON:API specification. +/// /// The type of the resource identifier object in the property. -public class JsonApiRelationship : JsonApiRelationship where T : JsonApiResourceIdentifier +public class JsonApiRelationship : JsonApiRelationship where T : JsonApiResourceIdentifier { /// /// Gets or sets the data payload associated with the response or request. - /// - public new T? Data { get; set; } + /// + public new T? Data { get; set; } } /// -/// Represents a relationship object with a generic resource identifier collection type as defined in section 7.2.2.2 -/// of the JSON:API specification. -/// +/// Represents a relationship object with a generic resource identifier collection type as defined in section 7.2.2.2 +/// of the JSON:API specification. +/// /// /// The type of the resource identifier collection object in the property. /// -public class JsonApiCollectionRelationship : JsonApiRelationship where T : IEnumerable +public class JsonApiCollectionRelationship : JsonApiRelationship where T : IEnumerable { /// /// Gets or sets the data payload associated with the response or request. - /// - public new T? Data { get; set; } + /// + public new T? Data { get; set; } } \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiResource.cs b/Crews.Web.JsonApiClient/JsonApiResource.cs index ed689f6..42af458 100644 --- a/Crews.Web.JsonApiClient/JsonApiResource.cs +++ b/Crews.Web.JsonApiClient/JsonApiResource.cs @@ -33,15 +33,29 @@ public class JsonApiResource : JsonApiResourceIdentifier public JsonObject? Metadata { get; set; } } +/// +/// Represents a resource object with a generic type as defined in section 7.2 of the JSON:API +/// specification. +/// +/// The type of the property. +public class JsonApiResource : JsonApiResource +{ + /// + /// Gets or sets the collection of custom attributes associated with this object. + /// + public new T? Attributes { get; set; } +} + /// -/// Represents a resource object with a generic type as defined in section 7.2 of the JSON:API -/// specification. +/// Represents a JSON:API resource object with customizable attributes and relationships. /// -/// The type of the property. -public class JsonApiResource : JsonApiResource -{ - /// - /// Gets or sets the collection of custom attributes associated with this object. - /// - public new T? Attributes { get; set; } +/// The type used to represent the attributes of the resource object. +/// The type used to represent the relationships associated with the resource object. +public class JsonApiResource : JsonApiResource +{ + /// + /// Gets or sets the collection of relationships associated with this object. + /// + [JsonPropertyName("relationships")] + public new TRelationships? Relationships { get; set; } } \ No newline at end of file From f128e3cb8f834e603babc023089c32d4f5b92023 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Thu, 4 Dec 2025 20:57:07 -0600 Subject: [PATCH 03/12] Update CLAUDE.md. --- CLAUDE.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb88f29..251dce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ The library follows a **composition-based architecture** aligned with the JSON:A ### Core Hierarchy ``` -JsonApiDocument (abstract base) +JsonApiDocument (base class) ├── Data (JsonElement?) - primary payload ├── Errors (IEnumerable?) ├── Included (IEnumerable?) @@ -53,6 +53,10 @@ JsonApiDocument (abstract base) ├── Metadata (JsonObject?) ├── JsonApi (JsonApiInfo?) └── Extensions (Dictionary?) + ├── JsonApiDocument - strongly-typed single resource document + │ └── Data (T?) where T : JsonApiResource + └── JsonApiCollectionDocument - strongly-typed collection document + └── Data (T?) where T : IEnumerable JsonApiResource (extends JsonApiResourceIdentifier) ├── Type/Id/LocalId (identification) @@ -61,12 +65,21 @@ JsonApiResource (extends JsonApiResourceIdentifier) ├── Links (JsonApiLinksObject?) ├── Metadata (JsonObject?) └── Extensions (Dictionary?) + ├── JsonApiResource - strongly-typed attributes + │ └── Attributes (T?) + └── JsonApiResource - strongly-typed attributes and relationships + ├── Attributes (TAttributes?) + └── Relationships (TRelationships?) JsonApiRelationship ├── Links (JsonApiLinksObject?) ├── Data (JsonElement?) - ResourceIdentifier or array ├── Metadata (JsonObject?) └── Extensions (Dictionary?) + ├── JsonApiRelationship - strongly-typed single resource identifier + │ └── Data (T?) where T : JsonApiResourceIdentifier + └── JsonApiCollectionRelationship - strongly-typed identifier collection + └── Data (T?) where T : IEnumerable JsonApiLink ├── Href (Uri) - required @@ -81,9 +94,12 @@ JsonApiLink ### Key Design Patterns -1. **Abstract Base Class**: `JsonApiDocument` defines the contract for all JSON:API documents and provides helper methods: +1. **Base Class with Generic Subclasses**: `JsonApiDocument`, `JsonApiResource`, and `JsonApiRelationship` serve as flexible base classes using `JsonElement`/`JsonObject` for weakly-typed scenarios, while generic subclasses provide compile-time type safety: + - `JsonApiDocument` / `JsonApiCollectionDocument` - strongly-typed document data + - `JsonApiResource` / `JsonApiResource` - strongly-typed resource attributes and relationships + - `JsonApiRelationship` / `JsonApiCollectionRelationship` - strongly-typed relationship data - `HasSingleResource` / `HasCollectionResource` / `HasErrors` - check document type - - `GetResource()` / `GetResourceCollection()` - safe deserialization + - Static `Deserialize()` methods on all document types for easy JSON deserialization 2. **Dual-Format Serialization**: `JsonApiLinkConverter` handles JSON:API links, which can be either: - Simple strings: `"https://example.com"` @@ -93,20 +109,35 @@ JsonApiLink 4. **Extension Points**: `[JsonExtensionData]` attributes enable JSON:API extensions without code changes -5. **Flexible Data Storage**: `JsonObject` and `JsonElement` used for Attributes, Metadata, and relationship Data to avoid premature schema commitments +5. **Flexible Data Storage**: `JsonObject` and `JsonElement` used in base classes for Attributes, Metadata, and relationship Data to avoid premature schema commitments, with generic subclasses available when schema is known 6. **Nullable Reference Types**: All properties properly annotated with nullable reference types for compile-time null safety ### Data Flow +**Weakly-Typed Approach (flexible, schema-agnostic):** ``` Raw JSON:API Response - ↓ (System.Text.Json deserializes) -JsonApiDocument instance + ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize()) +JsonApiDocument instance (Data as JsonElement) ↓ (check HasErrors, HasSingleResource, HasCollectionResource) + ↓ (manually deserialize Data property) JsonApiResource object(s) ├── Access Attributes (JsonObject for flexible schema) - ├── Follow Relationships (to other resources via JsonApiRelationship) + ├── Follow Relationships (Dictionary) + ├── Navigate via Links (hypermedia via JsonApiLink) + └── Read Metadata (JsonObject) +``` + +**Strongly-Typed Approach (compile-time safety):** +``` +Raw JSON:API Response + ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize>()) +JsonApiDocument instance (Data as MyResource) + ↓ (check HasErrors) +MyResource object (extends JsonApiResource) + ├── Access Attributes (MyAttributes with typed properties) + ├── Follow Relationships (MyRelationships with typed JsonApiRelationship properties) ├── Navigate via Links (hypermedia via JsonApiLink) └── Read Metadata (JsonObject) ``` @@ -132,7 +163,8 @@ JsonApiResource object(s) - Comprehensive test coverage for `JsonApiDocument` including: - All property deserialization and serialization - Helper methods (`HasSingleResource`, `HasCollectionResource`, `HasErrors`) - - Resource extraction methods (`GetResource()`, `GetResourceCollection()`) + - Static `Deserialize()` methods on document classes + - Generic subclass deserialization for strongly-typed scenarios - Roundtrip serialization tests - Extension data handling @@ -183,8 +215,83 @@ The library has comprehensive test coverage across all major components: - **JsonApiDocumentTests.cs**: 31 tests covering all aspects of the document model - HasSingleResource, HasCollectionResource, HasErrors property tests - - GetResource() and GetResourceCollection() method tests + - Static Deserialize() method tests - Property deserialization (JsonApi, Links, Included, Metadata, Errors, Extensions) - Serialization and roundtrip tests for all document types + - **Note**: Tests for generic subclasses (`JsonApiDocument`, `JsonApiCollectionDocument`, `JsonApiResource`, `JsonApiRelationship`) may need to be added - **JsonApiLinkConverterTests.cs**: Tests for dual-format link serialization - **MediaTypeHeaderBuilderTests.cs**: Tests for fluent header construction with extensions and profiles + +## Changes in `dev` Branch (vs. `master`) + +The `dev` branch introduces **generic subclasses** that enable strongly-typed deserialization while maintaining backward compatibility with the weakly-typed base classes: + +### New Generic Classes + +1. **JsonApiDocument** - Strongly-typed single resource document + - `Data` property is typed as `T?` where `T : JsonApiResource` + - Includes static `Deserialize()` method for easy JSON parsing + - Example: `JsonApiDocument.Deserialize(json)` + +2. **JsonApiCollectionDocument** - Strongly-typed collection document + - `Data` property is typed as `T?` where `T : IEnumerable` + - Includes static `Deserialize()` method + - Example: `JsonApiCollectionDocument>.Deserialize(json)` + +3. **JsonApiResource** - Resource with strongly-typed attributes + - `Attributes` property is typed as `T?` instead of `JsonObject?` + - Example: Define `class UserResource : JsonApiResource` + +4. **JsonApiResource** - Resource with strongly-typed attributes and relationships + - `Attributes` property is typed as `TAttributes?` + - `Relationships` property is typed as `TRelationships?` instead of `Dictionary?` + - Example: `class UserResource : JsonApiResource` + +5. **JsonApiRelationship** - Relationship with strongly-typed single resource identifier + - `Data` property is typed as `T?` where `T : JsonApiResourceIdentifier` + - Example: Used in relationship objects for to-one relationships + +6. **JsonApiCollectionRelationship** - Relationship with strongly-typed resource identifier collection + - `Data` property is typed as `T?` where `T : IEnumerable` + - Example: Used in relationship objects for to-many relationships + +### API Changes + +**Removed Methods** (from `JsonApiDocument`): +- `GetResource()` - Previously used to deserialize `Data` as a single resource +- `GetResourceCollection()` - Previously used to deserialize `Data` as a resource collection + +**Added Methods**: +- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Static deserialization +- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Strongly-typed static deserialization +- `JsonApiCollectionDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Strongly-typed static deserialization + +### Migration Guide (master → dev) + +**Before (master branch - weakly-typed):** +```csharp +var doc = JsonSerializer.Deserialize(json); +var resource = doc.GetResource(); +var userName = resource?.Attributes?["userName"]?.GetString(); +``` + +**After (dev branch - strongly-typed option):** +```csharp +var doc = JsonApiDocument.Deserialize(json); +var userName = doc.Data?.Attributes?.UserName; +``` + +**Or continue using weakly-typed approach:** +```csharp +var doc = JsonApiDocument.Deserialize(json); +var resource = doc.Data?.Deserialize(); +var userName = resource?.Attributes?["userName"]?.GetString(); +``` + +### Benefits of Generic Subclasses + +- **Compile-time type safety**: Catch errors at compile time instead of runtime +- **IntelliSense support**: Auto-completion for properties on typed attributes and relationships +- **Refactoring support**: IDE can track property renames and updates +- **Backward compatibility**: Base classes remain unchanged, existing code continues to work +- **Opt-in**: Use generics only when beneficial; fall back to flexible `JsonObject`/`JsonElement` when schema is unknown From 1958ae2d5a6ac521269ac70b29f0fe2b62dd6759 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Thu, 4 Dec 2025 21:06:39 -0600 Subject: [PATCH 04/12] Add allowed Claude methods. --- .claude/settings.local.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c9fa251 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)", + "Bash(git checkout:*)" + ], + "deny": [], + "ask": [] + } +} From 3d551dabc073b7b8ad17adaa4835aca271758c64 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Thu, 4 Dec 2025 21:52:15 -0600 Subject: [PATCH 05/12] Further refine classes and tests. --- .../JsonApiDocumentTests.cs | 223 +++++------------- Crews.Web.JsonApiClient/JsonApiDocument.cs | 91 +++++-- Crews.Web.JsonApiClient/JsonApiResource.cs | 8 +- 3 files changed, 130 insertions(+), 192 deletions(-) diff --git a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs index fe26845..f00a4f5 100644 --- a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs +++ b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs @@ -155,164 +155,6 @@ public void HasErrorsReturnsFalseWhenErrorsEmpty() #endregion - #region GetResource Tests - - [Fact(DisplayName = "GetResource deserializes single resource object")] - public void GetResourceDeserializesSingleResource() - { - const string json = """ - { - "data": { - "type": "articles", - "id": "1", - "attributes": { - "title": "JSON:API paints my bikeshed!" - } - } - } - """; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - JsonApiResource? resource = doc.GetResource(); - Assert.NotNull(resource); - Assert.Equal("articles", resource.Type); - Assert.Equal("1", resource.Id); - Assert.NotNull(resource.Attributes); - Assert.Equal("JSON:API paints my bikeshed!", resource.Attributes["title"]!.GetValue()); - } - - [Fact(DisplayName = "GetResource returns null when Data is null")] - public void GetResourceReturnsNullWhenDataNull() - { - const string json = """{"data": null}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - JsonApiResource? resource = doc.GetResource(); - Assert.Null(resource); - } - - [Fact(DisplayName = "GetResource returns null when Data is not present")] - public void GetResourceReturnsNullWhenDataNotPresent() - { - const string json = """{"meta": {"version": "1.0"}}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - JsonApiResource? resource = doc.GetResource(); - Assert.Null(resource); - } - - [Fact(DisplayName = "GetResource throws InvalidOperationException when Data is an array")] - public void GetResourceThrowsExceptionWhenDataIsArray() - { - const string json = """{"data": [{"type": "articles", "id": "1"}]}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - InvalidOperationException ex = Assert.Throws(() => doc.GetResource()); - Assert.Equal("Data is not an object; use GetResourceCollection if Data is an array", ex.Message); - } - - #endregion - - #region GetResourceCollection Tests - - [Fact(DisplayName = "GetResourceCollection deserializes resource array")] - public void GetResourceCollectionDeserializesResourceArray() - { - const string json = """ - { - "data": [ - { - "type": "articles", - "id": "1", - "attributes": { - "title": "First Article" - } - }, - { - "type": "articles", - "id": "2", - "attributes": { - "title": "Second Article" - } - } - ] - } - """; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - IEnumerable? resources = doc.GetResourceCollection(); - Assert.NotNull(resources); - JsonApiResource[] resourceArray = resources.ToArray(); - Assert.Equal(2, resourceArray.Length); - Assert.Equal("articles", resourceArray[0].Type); - Assert.Equal("1", resourceArray[0].Id); - Assert.Equal("First Article", resourceArray[0].Attributes!["title"]!.GetValue()); - Assert.Equal("articles", resourceArray[1].Type); - Assert.Equal("2", resourceArray[1].Id); - Assert.Equal("Second Article", resourceArray[1].Attributes!["title"]!.GetValue()); - } - - [Fact(DisplayName = "GetResourceCollection returns empty array for empty data array")] - public void GetResourceCollectionReturnsEmptyArrayForEmptyData() - { - const string json = """{"data": []}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - IEnumerable? resources = doc.GetResourceCollection(); - Assert.NotNull(resources); - Assert.Empty(resources); - } - - [Fact(DisplayName = "GetResourceCollection returns null when Data is null")] - public void GetResourceCollectionReturnsNullWhenDataNull() - { - const string json = """{"data": null}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - IEnumerable? resources = doc.GetResourceCollection(); - Assert.Null(resources); - } - - [Fact(DisplayName = "GetResourceCollection returns null when Data is not present")] - public void GetResourceCollectionReturnsNullWhenDataNotPresent() - { - const string json = """{"meta": {"version": "1.0"}}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - IEnumerable? resources = doc.GetResourceCollection(); - Assert.Null(resources); - } - - [Fact(DisplayName = "GetResourceCollection throws InvalidOperationException when Data is an object")] - public void GetResourceCollectionThrowsExceptionWhenDataIsObject() - { - const string json = """{"data": {"type": "articles", "id": "1"}}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - InvalidOperationException ex = Assert.Throws(() => doc.GetResourceCollection()); - Assert.Equal("Data is not an array; use GetResource if Data is an object", ex.Message); - } - - #endregion - #region Property Deserialization Tests [Fact(DisplayName = "Deserializes document with JsonApi property")] @@ -566,8 +408,8 @@ public void RoundtripSerializationPreservesSingleResourceDocument() Assert.NotNull(deserialized); Assert.True(deserialized.HasSingleResource); - JsonApiResource? resource = deserialized.GetResource(); - Assert.NotNull(resource); + JsonApiResource? resource = JsonSerializer.Deserialize((JsonElement)deserialized.Data!); + Assert.NotNull(resource); Assert.Equal("articles", resource.Type); Assert.Equal("1", resource.Id); Assert.Equal("Test Article", resource.Attributes!["title"]!.GetValue()); @@ -595,8 +437,8 @@ public void RoundtripSerializationPreservesCollectionResourceDocument() Assert.NotNull(deserialized); Assert.True(deserialized.HasCollectionResource); - IEnumerable? resources = deserialized.GetResourceCollection(); - Assert.NotNull(resources); + IEnumerable? resources = JsonSerializer.Deserialize>((JsonElement)deserialized.Data!); + Assert.NotNull(resources); JsonApiResource[] resourceArray = resources.ToArray(); Assert.Equal(3, resourceArray.Length); Assert.Equal("1", resourceArray[0].Id); @@ -634,5 +476,60 @@ public void RoundtripSerializationPreservesErrorDocument() Assert.Equal("Name is required", error.Details); } - #endregion + #endregion + + #region Deserialize Static Method Tests + + [Fact(DisplayName = "Deserialize static method returns null for null JSON")] + public void DeserializeStaticMethodReturnsNullForNullJson() + { + const string invalidJson = """null"""; + JsonApiDocument? doc = JsonApiDocument.Deserialize(invalidJson, _options); + Assert.Null(doc); + } + + [Fact(DisplayName = "Deserialize static method returns valid document for valid JSON")] + public void DeserializeStaticMethodReturnsValidDocumentForValidJson() + { + const string validJson = """{"data": {"type": "articles", "id": "1"}}"""; + JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); + Assert.NotNull(doc); + Assert.True(doc.HasSingleResource); + } + + [Fact(DisplayName = "Deserialize generic static method returns null for null JSON")] + public void DeserializeGenericStaticMethodReturnsNullForNullJson() + { + const string invalidJson = """null"""; + JsonApiDocument? doc = JsonApiDocument.Deserialize(invalidJson, _options); + Assert.Null(doc); + } + + [Fact(DisplayName = "Deserialize generic static method returns valid document for valid JSON")] + public void DeserializeGenericStaticMethodReturnsValidDocumentForValidJson() + { + const string validJson = """{"data": {"type": "articles", "id": "1"}}"""; + JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); + Assert.NotNull(doc); + Assert.True(doc.HasSingleResource); + } + + [Fact(DisplayName = "DeserializeCollection generic static method returns null for null JSON")] + public void DeserializeCollectionGenericStaticMethodReturnsNullForNullJson() + { + const string invalidJson = """null"""; + JsonApiCollectionDocument>? doc = JsonApiDocument.DeserializeCollection>(invalidJson, _options); + Assert.Null(doc); + } + + [Fact(DisplayName = "DeserializeCollection generic static method returns valid document for valid JSON")] + public void DeserializeCollectionGenericStaticMethodReturnsValidDocumentForValidJson() + { + const string validJson = """{"data": [{"type": "articles", "id": "1"}, {"type": "articles", "id": "2"}]}"""; + JsonApiCollectionDocument>? doc = JsonApiDocument.DeserializeCollection>(validJson, _options); + Assert.NotNull(doc); + Assert.True(doc.HasCollectionResource); + } + + #endregion } diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index d395594..e1f6090 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Collections; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -79,7 +80,7 @@ public class JsonApiDocument /// /// Deserializes the specified JSON string into a instance. /// - /// This method uses for deserialization. The input + /// This method uses for deserialization. The input /// JSON must conform to the JSON:API specification for successful parsing. /// The JSON string representing a JSON:API document to deserialize. /// Optional serialization options to control the deserialization behavior. @@ -89,13 +90,44 @@ public class JsonApiDocument /// public static JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) => JsonSerializer.Deserialize(json, options); + + /// + /// Deserializes the specified JSON string into a JSON:API document with a user-defined data object. + /// + /// This method uses for deserialization. The input + /// JSON must conform to the JSON:API specification for successful parsing. + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization behavior. + /// + /// A instance representing the deserialized data, or if + /// the input is invalid or does not match the expected format. + /// + public static JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) + where T : JsonApiResource + => JsonSerializer.Deserialize>(json, options); + + + /// + /// Deserializes the specified JSON string into a JSON:API document with a user-defined data collection. + /// + /// This method uses for deserialization. The input + /// JSON must conform to the JSON:API specification for successful parsing. + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization behavior. + /// + /// A instance representing the deserialized data, or if the input is invalid or does not match the expected format. + /// + public static JsonApiCollectionDocument? DeserializeCollection(string json, JsonSerializerOptions? options = null) + where T : IEnumerable + => JsonSerializer.Deserialize>(json, options); } /// /// Represents a JSON:API top-level object with a generic single resource type as defined in section 7.1 of the /// JSON:API specification. /// -/// +/// The derived type. public class JsonApiDocument : JsonApiDocument where T : JsonApiResource { /// @@ -105,26 +137,29 @@ public class JsonApiDocument : JsonApiDocument where T : JsonApiResource public new T? Data { get; set; } /// - /// Deserializes the specified JSON string into a strongly typed instance. + /// Gets a value indicating whether the property contains a single resource object. /// - /// This method uses for deserialization. The - /// generic type parameter must match the expected resource type in the JSON:API - /// document. - /// The JSON string representing a JSON:API document to deserialize. - /// Optional serialization options to control the deserialization process. - /// - /// A instance representing the deserialized JSON:API document, or if the input is null or invalid. - /// - public static new JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) - => JsonSerializer.Deserialize>(json, options); + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasSingleResource => true; + + /// + /// Gets a value indicating whether the property contains a resource collection object. + /// + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasCollectionResource => false; } /// /// Represents a JSON:API top-level object with a generic resource collection type as defined in section 7.1 of the /// JSON:API specification. /// -/// +/// The derived type public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerable { /// @@ -134,14 +169,20 @@ public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerabl public new T? Data { get; set; } /// - /// Deserializes the specified JSON string into a instance. + /// Gets a value indicating whether the property contains a single resource object. /// - /// The JSON string representing a collection document to deserialize. - /// Options to control the behavior of the deserialization. - /// - /// A instance representing the deserialized data, or if the input is null or empty. - /// - public static new JsonApiCollectionDocument? Deserialize(string json, JsonSerializerOptions? options = null) - => JsonSerializer.Deserialize>(json, options); + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasSingleResource => false; + + /// + /// Gets a value indicating whether the property contains a resource collection object. + /// + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasCollectionResource => true; } \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiResource.cs b/Crews.Web.JsonApiClient/JsonApiResource.cs index 42af458..a49600b 100644 --- a/Crews.Web.JsonApiClient/JsonApiResource.cs +++ b/Crews.Web.JsonApiClient/JsonApiResource.cs @@ -46,10 +46,10 @@ public class JsonApiResource : JsonApiResource public new T? Attributes { get; set; } } -/// -/// Represents a JSON:API resource object with customizable attributes and relationships. -/// -/// The type used to represent the attributes of the resource object. +/// +/// Represents a JSON:API resource object with customizable attributes and relationships. +/// +/// The type used to represent the attributes of the resource object. /// The type used to represent the relationships associated with the resource object. public class JsonApiResource : JsonApiResource { From c25c0759c6535d7ee1b213857ad777b78be53607 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Sun, 7 Dec 2025 07:07:34 -0600 Subject: [PATCH 06/12] WIP --- CHANGELOG.md | 20 + Crews.Web.JsonApiClient/JsonApiDocument.cs | 36 +- README.md | 404 +++++++++++++++++++-- 3 files changed, 432 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f40a8df..7813b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2025-12-07 + +### Added + +- Generic subclasses for strongly-typed deserialization: + - `JsonApiDocument` - Strongly-typed single resource document where `Data` is typed as `T?` (where `T : JsonApiResource`) + - `JsonApiCollectionDocument` - Strongly-typed collection document where `Data` is typed as `T?` (where `T : IEnumerable`) + - `JsonApiResource` - Resource with strongly-typed `Attributes` property (typed as `T?` instead of `JsonObject?`) + - `JsonApiResource` - Resource with strongly-typed `Attributes` and `Relationships` properties + - `JsonApiRelationship` - Relationship with strongly-typed single resource identifier where `Data` is typed as `T?` (where `T : JsonApiResourceIdentifier`) + - `JsonApiCollectionRelationship` - Relationship with strongly-typed resource identifier collection where `Data` is typed as `T?` (where `T : IEnumerable`) +- Static `Deserialize(string json, JsonSerializerOptions? options = null)` methods on all document classes for convenient JSON parsing +- Compile-time type safety and IntelliSense support for JSON:API responses when using generic subclasses + +### Removed + +- **Breaking change:** `GetResource()` method from `JsonApiDocument` (replaced by strongly-typed `JsonApiDocument.Data` property or manual deserialization of `JsonApiDocument.Data`) +- **Breaking change:** `GetResourceCollection()` method from `JsonApiDocument` (replaced by strongly-typed `JsonApiCollectionDocument.Data` property or manual deserialization of `JsonApiDocument.Data`) + ## [2.0.0] - 2025-12-01 ### Fixed @@ -15,5 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial release. +[3.0.0]: https://github.com/twcrews/jsonapi-client/compare/2.0.0...3.0.0 [2.0.0]: https://github.com/twcrews/jsonapi-client/compare/1.0.0...2.0.0 [1.0.0]: https://github.com/twcrews/jsonapi-client/releases/tag/1.0.0 \ No newline at end of file diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index e1f6090..9edd37b 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -128,13 +128,13 @@ public class JsonApiDocument /// JSON:API specification. /// /// The derived type. -public class JsonApiDocument : JsonApiDocument where T : JsonApiResource +public class JsonApiDocument : JsonApiDocument { /// /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] - public new T? Data { get; set; } + public new JsonApiResource? Data { get; set; } /// /// Gets a value indicating whether the property contains a single resource object. @@ -185,4 +185,36 @@ public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerabl /// langword="true"/>. /// public new bool HasCollectionResource => true; +} + +public class JsonApiDocument : JsonApiDocument + where TResource : JsonApiResource + where TIncluded : IEnumerable +{ + /// + /// Gets or sets the primary data payload associated with the document. + /// + [JsonPropertyName("data")] + public new TResource? Data { get; set; } + /// + /// Gets or sets the included property of the document. + /// + [JsonPropertyName("included")] + public new IEnumerable? Included { get; set; } + /// + /// Gets a value indicating whether the property contains a single resource object. + /// + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasSingleResource => true; + /// + /// Gets a value indicating whether the property contains a resource collection object. + /// + /// + /// Since this class is strongly typed to a single resource type, this property always returns . + /// + public new bool HasCollectionResource => false; } \ No newline at end of file diff --git a/README.md b/README.md index d270c6e..c9a1805 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ dotnet add package Crews.Web.JsonApiClient ## Quick Start -### Basic Deserialization +### Basic Deserialization (Weakly-Typed) ```csharp using System.Text.Json; @@ -20,7 +20,7 @@ using Crews.Web.JsonApiClient; // Deserialize a JSON:API document string json = /* your JSON:API document */; -var document = JsonSerializer.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); // Check what type of document you have if (document.HasErrors) @@ -32,26 +32,176 @@ if (document.HasErrors) } else if (document.HasSingleResource) { - var resource = document.GetResource(); - Console.WriteLine($"Resource: {resource.Type} with ID {resource.Id}"); + // Manually deserialize the Data property + var resource = document.Data?.Deserialize(); + Console.WriteLine($"Resource: {resource?.Type} with ID {resource?.Id}"); } else if (document.HasCollectionResource) { - var resources = document.GetResourceCollection(); - Console.WriteLine($"Found {resources.Count()} resources"); + // Manually deserialize the Data property + var resources = document.Data?.Deserialize>(); + Console.WriteLine($"Found {resources?.Count} resources"); +} +``` + +### Strongly-Typed Deserialization + +For better type safety and IntelliSense support, use the generic subclasses: + +```csharp +// Define your strongly-typed resource +public class Article : JsonApiResource +{ +} + +public class ArticleAttributes +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("publishedAt")] + public DateTime? PublishedAt { get; set; } +} + +public class ArticleRelationships +{ + [JsonPropertyName("author")] + public JsonApiRelationship? Author { get; set; } + + [JsonPropertyName("comments")] + public JsonApiCollectionRelationship>? Comments { get; set; } +} + +// Deserialize a single resource document +string json = /* your JSON:API document */; +var document = JsonApiDocument
.Deserialize(json); + +if (document.HasErrors) +{ + foreach (var error in document.Errors) + { + Console.WriteLine($"Error {error.Status}: {error.Title}"); + } +} +else if (document.Data != null) +{ + // Data is strongly-typed as Article + Console.WriteLine($"Title: {document.Data.Attributes?.Title}"); + Console.WriteLine($"Published: {document.Data.Attributes?.PublishedAt}"); + + // Access typed relationships + var authorId = document.Data.Relationships?.Author?.Data?.Id; + Console.WriteLine($"Author ID: {authorId}"); +} + +// Deserialize a collection document +var collection = JsonApiCollectionDocument>.Deserialize(json); + +if (collection.Data != null) +{ + foreach (var article in collection.Data) + { + Console.WriteLine($"Article: {article.Attributes?.Title}"); + } +} +``` + +### Complete Real-World Example + +Here's a complete example showing how to define and use strongly-typed resources: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using Crews.Web.JsonApiClient; + +// Define your resource types +public class User : JsonApiResource +{ +} + +public class UserAttributes +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime? CreatedAt { get; set; } +} + +public class UserRelationships +{ + [JsonPropertyName("posts")] + public JsonApiCollectionRelationship>? Posts { get; set; } + + [JsonPropertyName("profile")] + public JsonApiRelationship? Profile { get; set; } +} + +// Use the types +string json = """ +{ + "data": { + "type": "users", + "id": "123", + "attributes": { + "name": "John Doe", + "email": "john@example.com", + "createdAt": "2024-01-15T10:30:00Z" + }, + "relationships": { + "posts": { + "data": [ + { "type": "posts", "id": "1" }, + { "type": "posts", "id": "2" } + ] + }, + "profile": { + "data": { "type": "profiles", "id": "456" } + } + } + } +} +"""; + +var document = JsonApiDocument.Deserialize(json); + +// Access with full type safety and IntelliSense +if (document.Data != null) +{ + Console.WriteLine($"User: {document.Data.Attributes?.Name}"); + Console.WriteLine($"Email: {document.Data.Attributes?.Email}"); + Console.WriteLine($"Created: {document.Data.Attributes?.CreatedAt}"); + + // Access typed relationships + var posts = document.Data.Relationships?.Posts?.Data; + Console.WriteLine($"Number of posts: {posts?.Count ?? 0}"); + + var profileId = document.Data.Relationships?.Profile?.Data?.Id; + Console.WriteLine($"Profile ID: {profileId}"); } ``` ### Working with Resources +#### Weakly-Typed Approach + ```csharp +// Deserialize manually from Data property +var resource = document.Data?.Deserialize(); + // Access resource identification -var resource = document.GetResource(); -Console.WriteLine($"Type: {resource.Type}"); -Console.WriteLine($"ID: {resource.Id}"); +Console.WriteLine($"Type: {resource?.Type}"); +Console.WriteLine($"ID: {resource?.Id}"); // Access attributes (flexible JSON object) -if (resource.Attributes != null) +if (resource?.Attributes != null) { var title = resource.Attributes["title"]?.GetValue(); var publishedAt = resource.Attributes["publishedAt"]?.GetValue(); @@ -59,24 +209,60 @@ if (resource.Attributes != null) } // Access metadata -if (resource.Metadata != null) +if (resource?.Metadata != null) { var copyright = resource.Metadata["copyright"]?.GetValue(); Console.WriteLine($"Copyright: {copyright}"); } // Navigate links -if (resource.Links?.Self != null) +if (resource?.Links?.Self != null) { Console.WriteLine($"Self link: {resource.Links.Self.Href}"); } ``` +#### Strongly-Typed Approach + +```csharp +// Use strongly-typed document +var document = JsonApiDocument
.Deserialize(json); + +// Access resource identification +Console.WriteLine($"Type: {document.Data?.Type}"); +Console.WriteLine($"ID: {document.Data?.Id}"); + +// Access strongly-typed attributes with IntelliSense +if (document.Data?.Attributes != null) +{ + var title = document.Data.Attributes.Title; + var publishedAt = document.Data.Attributes.PublishedAt; + Console.WriteLine($"{title} published at {publishedAt}"); +} + +// Access metadata (still flexible JSON object) +if (document.Data?.Metadata != null) +{ + var copyright = document.Data.Metadata["copyright"]?.GetValue(); + Console.WriteLine($"Copyright: {copyright}"); +} + +// Navigate links +if (document.Data?.Links?.Self != null) +{ + Console.WriteLine($"Self link: {document.Data.Links.Self.Href}"); +} +``` + ### Working with Relationships +#### Weakly-Typed Approach + ```csharp +var resource = document.Data?.Deserialize(); + // Access relationships -if (resource.Relationships != null && +if (resource?.Relationships != null && resource.Relationships.TryGetValue("author", out var authorRel)) { // Get related resource identifier @@ -91,6 +277,37 @@ if (resource.Relationships != null && } ``` +#### Strongly-Typed Approach + +```csharp +var document = JsonApiDocument
.Deserialize(json); + +// Access strongly-typed relationships +var authorRel = document.Data?.Relationships?.Author; +if (authorRel != null) +{ + // Data is strongly-typed as JsonApiResourceIdentifier + Console.WriteLine($"Author: {authorRel.Data?.Type}/{authorRel.Data?.Id}"); + + // Navigate relationship links + if (authorRel.Links?.Related != null) + { + Console.WriteLine($"Fetch author at: {authorRel.Links.Related.Href}"); + } +} + +// Access collection relationships +var commentsRel = document.Data?.Relationships?.Comments; +if (commentsRel?.Data != null) +{ + Console.WriteLine($"Comment count: {commentsRel.Data.Count}"); + foreach (var comment in commentsRel.Data) + { + Console.WriteLine($"Comment ID: {comment.Id}"); + } +} +``` + ### Working with Included Resources ```csharp @@ -109,21 +326,48 @@ if (document.Included != null) ### Handling Collections +#### Weakly-Typed Approach + ```csharp -// Process a collection of resources -var articles = document.GetResourceCollection(); +// Deserialize collection manually +var articles = document.Data?.Deserialize>(); -foreach (var article in articles) +if (articles != null) { - var title = article.Attributes?["title"]?.GetValue(); - Console.WriteLine($"Article: {title}"); + foreach (var article in articles) + { + var title = article.Attributes?["title"]?.GetValue(); + Console.WriteLine($"Article: {title}"); + } +} + +// Access collection-level links +if (document.Links?.Next != null) +{ + Console.WriteLine($"Next page: {document.Links.Next.Href}"); +} +``` - // Access collection-level links - if (document.Links?.Next != null) +#### Strongly-Typed Approach + +```csharp +// Use strongly-typed collection document +var collection = JsonApiCollectionDocument>.Deserialize(json); + +if (collection.Data != null) +{ + foreach (var article in collection.Data) { - Console.WriteLine($"Next page: {document.Links.Next.Href}"); + // Access strongly-typed attributes + Console.WriteLine($"Article: {article.Attributes?.Title}"); } } + +// Access collection-level links +if (collection.Links?.Next != null) +{ + Console.WriteLine($"Next page: {collection.Links.Next.Href}"); +} ``` ### HTTP Client Integration @@ -146,10 +390,13 @@ client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(mediaType.MediaType.ToString()) ); -// Make request +// Make request and deserialize (weakly-typed) var response = await client.GetAsync("https://api.example.com/articles"); var json = await response.Content.ReadAsStringAsync(); -var document = JsonSerializer.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); + +// Or use strongly-typed deserialization +var typedDocument = JsonApiCollectionDocument>.Deserialize(json); ``` ### Error Handling @@ -232,15 +479,120 @@ var json = JsonSerializer.Serialize(newDocument, new JsonSerializerOptions }); ``` +## When to Use Which Approach + +### Use Strongly-Typed (Generic Subclasses) When: + +- You have a **known, stable schema** for your JSON:API resources +- You want **compile-time type safety** and catch errors early +- You need **IntelliSense/autocomplete** support in your IDE +- You're building a **client for a specific API** with well-defined resource types +- You want **refactoring support** (rename properties, find usages, etc.) + +### Use Weakly-Typed (Base Classes) When: + +- You're working with **dynamic or unknown schemas** +- The API schema **changes frequently** or varies by endpoint +- You're building **generic tooling** that works with any JSON:API endpoint +- You need **maximum flexibility** to handle diverse response structures +- You're **exploring an API** and don't want to define types upfront + +### Mixing Both Approaches + +You can mix both approaches in the same application: + +```csharp +// Use strongly-typed for known resources +var articles = JsonApiCollectionDocument>.Deserialize(articlesJson); + +// Use weakly-typed for dynamic/unknown resources +var unknownDoc = JsonApiDocument.Deserialize(dynamicJson); +var resource = unknownDoc.Data?.Deserialize(); +``` + ## Features +- **Dual typing approach** - Choose between weakly-typed (flexible, schema-agnostic) or strongly-typed (compile-time safety, IntelliSense) deserialization +- **Generic subclasses** for strongly-typed resources, relationships, and documents with full type safety +- **Static deserialization methods** on all document classes for convenient JSON parsing - **Strongly-typed models** for all JSON:API specification elements -- **Flexible attribute storage** using `JsonObject` for dynamic schemas +- **Flexible attribute storage** using `JsonObject` for dynamic schemas (or strongly-typed for known schemas) - **Dual-format link support** (string URLs or rich link objects) - **Extension support** via `[JsonExtensionData]` for custom JSON:API extensions -- **Helper methods** for safe resource extraction and type checking -- **HTTP header utilities** for building spec-compliant Content-Type headers +- **Helper methods** for safe document type checking (`HasErrors`, `HasSingleResource`, `HasCollectionResource`) +- **HTTP header utilities** for building spec-compliant Content-Type headers with extensions and profiles - **.NET 8.0 target** with nullable reference types enabled +- **Backward compatible** - existing code continues to work with base classes + +## Migration Guide (v2.0.0 → v3.0.0) + +Version 3.0.0 removes the `GetResource()` and `GetResourceCollection()` methods in favor of strongly-typed generic subclasses and manual deserialization. Here's how to migrate: + +### Before (v2.0.0) + +```csharp +var document = JsonSerializer.Deserialize(json); + +// Get single resource +var resource = document.GetResource(); +var title = resource?.Attributes?["title"]?.GetValue(); + +// Get collection +var resources = document.GetResourceCollection(); +foreach (var resource in resources) +{ + // Process resource +} +``` + +### After (v3.0.0) - Option 1: Weakly-Typed + +```csharp +var document = JsonApiDocument.Deserialize(json); + +// Get single resource +var resource = document.Data?.Deserialize(); +var title = resource?.Attributes?["title"]?.GetValue(); + +// Get collection +var resources = document.Data?.Deserialize>(); +if (resources != null) +{ + foreach (var resource in resources) + { + // Process resource + } +} +``` + +### After (v3.0.0) - Option 2: Strongly-Typed (Recommended) + +```csharp +// Define your types once +public class Article : JsonApiResource +{ +} + +public class ArticleAttributes +{ + [JsonPropertyName("title")] + public string? Title { get; set; } +} + +// Use strongly-typed deserialization +var document = JsonApiDocument
.Deserialize(json); +var title = document.Data?.Attributes?.Title; // Full IntelliSense support! + +// Or for collections +var collection = JsonApiCollectionDocument>.Deserialize(json); +if (collection.Data != null) +{ + foreach (var article in collection.Data) + { + Console.WriteLine(article.Attributes?.Title); + } +} +``` ## Documentation From 446fcd55a463fff608ceeb90b06c174d0ace4a83 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 13:56:47 -0600 Subject: [PATCH 07/12] Simplified generic document. --- Crews.Web.JsonApiClient/JsonApiDocument.cs | 94 +++------------------- 1 file changed, 12 insertions(+), 82 deletions(-) diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index 9edd37b..d682e71 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -1,5 +1,4 @@ -using System.Collections; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -54,15 +53,6 @@ public class JsonApiDocument [JsonExtensionData] public Dictionary? Extensions { get; set; } - /// - /// Gets a value indicating whether the property contains a single resource object. - /// - /// - /// This property returns if is a JSON object. No other validation or - /// type checking is performed. - /// - public bool HasSingleResource => Data?.ValueKind == JsonValueKind.Object; - /// /// Gets a value indicating whether the property contains a resource collection object. /// @@ -106,20 +96,18 @@ public class JsonApiDocument where T : JsonApiResource => JsonSerializer.Deserialize>(json, options); - /// - /// Deserializes the specified JSON string into a JSON:API document with a user-defined data collection. + /// Deserializes the specified JSON string into a JSON:API document with a user-defined collection of data objects. /// - /// This method uses for deserialization. The input - /// JSON must conform to the JSON:API specification for successful parsing. + /// The underlying type of each item in the collection. /// The JSON string representing a JSON:API document to deserialize. /// Optional serialization options to control the deserialization behavior. /// - /// A instance representing the deserialized data, or if the input is invalid or does not match the expected format. + /// A instance representing the deserialized data, or if + /// the input is invalid or does not match the expected format. /// - public static JsonApiCollectionDocument? DeserializeCollection(string json, JsonSerializerOptions? options = null) - where T : IEnumerable + public static JsonApiCollectionDocument? DeserializeCollection( + string json, JsonSerializerOptions? options = null) => JsonSerializer.Deserialize>(json, options); } @@ -127,7 +115,7 @@ public class JsonApiDocument /// Represents a JSON:API top-level object with a generic single resource type as defined in section 7.1 of the /// JSON:API specification. ///
-/// The derived type. +/// The underlying resource type. public class JsonApiDocument : JsonApiDocument { /// @@ -139,82 +127,24 @@ public class JsonApiDocument : JsonApiDocument /// /// Gets a value indicating whether the property contains a single resource object. /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// - public new bool HasSingleResource => true; - - /// - /// Gets a value indicating whether the property contains a resource collection object. - /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// public new bool HasCollectionResource => false; } /// -/// Represents a JSON:API top-level object with a generic resource collection type as defined in section 7.1 of the +/// Represents a JSON:API top-level object with a generic collection resource type as defined in section 7.1 of the /// JSON:API specification. /// -/// The derived type -public class JsonApiCollectionDocument : JsonApiDocument where T : IEnumerable +/// The underlying resource type. +public class JsonApiCollectionDocument : JsonApiDocument { /// /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] - public new T? Data { get; set; } - - /// - /// Gets a value indicating whether the property contains a single resource object. - /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// - public new bool HasSingleResource => false; + public new IEnumerable>? Data { get; set; } /// /// Gets a value indicating whether the property contains a resource collection object. /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// public new bool HasCollectionResource => true; -} - -public class JsonApiDocument : JsonApiDocument - where TResource : JsonApiResource - where TIncluded : IEnumerable -{ - /// - /// Gets or sets the primary data payload associated with the document. - /// - [JsonPropertyName("data")] - public new TResource? Data { get; set; } - /// - /// Gets or sets the included property of the document. - /// - [JsonPropertyName("included")] - public new IEnumerable? Included { get; set; } - /// - /// Gets a value indicating whether the property contains a single resource object. - /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// - public new bool HasSingleResource => true; - /// - /// Gets a value indicating whether the property contains a resource collection object. - /// - /// - /// Since this class is strongly typed to a single resource type, this property always returns . - /// - public new bool HasCollectionResource => false; } \ No newline at end of file From 85209c0845fcf82b0deb94bda2a76e6bedc6727b Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 13:57:00 -0600 Subject: [PATCH 08/12] Move constants class. --- Crews.Web.JsonApiClient/{ => Utility}/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Crews.Web.JsonApiClient/{ => Utility}/Constants.cs (95%) diff --git a/Crews.Web.JsonApiClient/Constants.cs b/Crews.Web.JsonApiClient/Utility/Constants.cs similarity index 95% rename from Crews.Web.JsonApiClient/Constants.cs rename to Crews.Web.JsonApiClient/Utility/Constants.cs index 29efc90..791e887 100644 --- a/Crews.Web.JsonApiClient/Constants.cs +++ b/Crews.Web.JsonApiClient/Utility/Constants.cs @@ -1,4 +1,4 @@ -namespace Crews.Web.JsonApiClient; +namespace Crews.Web.JsonApiClient.Utility; static class Constants { From 4ee12ea1db3d64adda8f4f8737eda2444d9ba929 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 13:57:13 -0600 Subject: [PATCH 09/12] Refactor tests. --- .../JsonApiDocumentTests.cs | 61 +------------------ 1 file changed, 3 insertions(+), 58 deletions(-) diff --git a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs index f00a4f5..11a1417 100644 --- a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs +++ b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs @@ -15,58 +15,6 @@ public JsonApiDocumentTests() // Concrete implementation for testing abstract JsonApiDocument private class TestJsonApiDocument : JsonApiDocument { } - #region HasSingleResource Tests - - [Fact(DisplayName = "HasSingleResource returns true when Data is an object")] - public void HasSingleResourceReturnsTrueForObject() - { - const string json = """{"data": {"type": "articles", "id": "1"}}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - Assert.True(doc.HasSingleResource); - Assert.False(doc.HasCollectionResource); - } - - [Fact(DisplayName = "HasSingleResource returns false when Data is an array")] - public void HasSingleResourceReturnsFalseForArray() - { - const string json = """{"data": [{"type": "articles", "id": "1"}]}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - Assert.False(doc.HasSingleResource); - Assert.True(doc.HasCollectionResource); - } - - [Fact(DisplayName = "HasSingleResource returns false when Data is null")] - public void HasSingleResourceReturnsFalseForNull() - { - const string json = """{"data": null}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - Assert.False(doc.HasSingleResource); - Assert.False(doc.HasCollectionResource); - } - - [Fact(DisplayName = "HasSingleResource returns false when Data is not present")] - public void HasSingleResourceReturnsFalseWhenDataNotPresent() - { - const string json = """{"meta": {"version": "1.0"}}"""; - - TestJsonApiDocument? doc = JsonSerializer.Deserialize(json, _options); - - Assert.NotNull(doc); - Assert.False(doc.HasSingleResource); - Assert.False(doc.HasCollectionResource); - } - - #endregion - #region HasCollectionResource Tests [Fact(DisplayName = "HasCollectionResource returns true when Data is an array")] @@ -78,7 +26,6 @@ public void HasCollectionResourceReturnsTrueForArray() Assert.NotNull(doc); Assert.True(doc.HasCollectionResource); - Assert.False(doc.HasSingleResource); } [Fact(DisplayName = "HasCollectionResource returns true when Data is an empty array")] @@ -90,7 +37,6 @@ public void HasCollectionResourceReturnsTrueForEmptyArray() Assert.NotNull(doc); Assert.True(doc.HasCollectionResource); - Assert.False(doc.HasSingleResource); } [Fact(DisplayName = "HasCollectionResource returns false when Data is an object")] @@ -102,7 +48,6 @@ public void HasCollectionResourceReturnsFalseForObject() Assert.NotNull(doc); Assert.False(doc.HasCollectionResource); - Assert.True(doc.HasSingleResource); } #endregion @@ -407,7 +352,7 @@ public void RoundtripSerializationPreservesSingleResourceDocument() TestJsonApiDocument? deserialized = JsonSerializer.Deserialize(serialized, _options); Assert.NotNull(deserialized); - Assert.True(deserialized.HasSingleResource); + Assert.False(deserialized.HasCollectionResource); JsonApiResource? resource = JsonSerializer.Deserialize((JsonElement)deserialized.Data!); Assert.NotNull(resource); Assert.Equal("articles", resource.Type); @@ -494,7 +439,7 @@ public void DeserializeStaticMethodReturnsValidDocumentForValidJson() const string validJson = """{"data": {"type": "articles", "id": "1"}}"""; JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); Assert.NotNull(doc); - Assert.True(doc.HasSingleResource); + Assert.False(doc.HasCollectionResource); } [Fact(DisplayName = "Deserialize generic static method returns null for null JSON")] @@ -511,7 +456,7 @@ public void DeserializeGenericStaticMethodReturnsValidDocumentForValidJson() const string validJson = """{"data": {"type": "articles", "id": "1"}}"""; JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); Assert.NotNull(doc); - Assert.True(doc.HasSingleResource); + Assert.False(doc.HasCollectionResource); } [Fact(DisplayName = "DeserializeCollection generic static method returns null for null JSON")] From c0d3db9f0d16f70068df0eff2a06dd91a74c8478 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 16:56:21 -0600 Subject: [PATCH 10/12] Complete updates. --- .claude/settings.local.json | 5 +- CHANGELOG.md | 8 +- CLAUDE.md | 59 ++- .../HttpResponseMessageExtensionsTests.cs | 193 ++++++++ .../JsonApiDocumentTests.cs | 18 +- .../Crews.Web.JsonApiClient.csproj | 1 - .../HttpResponseMessageExtensions.cs | 63 +++ Crews.Web.JsonApiClient/JsonApiDocument.cs | 64 ++- README.md | 452 ++++++++---------- 9 files changed, 550 insertions(+), 313 deletions(-) create mode 100644 Crews.Web.JsonApiClient.Tests/HttpResponseMessageExtensionsTests.cs create mode 100644 Crews.Web.JsonApiClient/HttpResponseMessageExtensions.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c9fa251..1365705 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "Bash(dotnet test:*)", - "Bash(git checkout:*)" + "Bash(git checkout:*)", + "Bash(dotnet build:*)", + "Bash(ls:*)", + "Bash(find /Users/tommyc/Repositories/twcrews/jsonapi-client/Crews.Web.JsonApiClient.Tests -name \"*Tests.cs\" -exec sh -c 'echo \"\"$1: $\\(grep -c \"\"public void\\\\|public async Task\\\\|public Task\"\" \"\"$1\"\"\\)\"\" ' _ {} ;)" ], "deny": [], "ask": [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7813b72..ebd6a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.0.0] - 2025-12-07 +## [3.0.0] - 2026-02-09 ### Added @@ -17,12 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `JsonApiRelationship` - Relationship with strongly-typed single resource identifier where `Data` is typed as `T?` (where `T : JsonApiResourceIdentifier`) - `JsonApiCollectionRelationship` - Relationship with strongly-typed resource identifier collection where `Data` is typed as `T?` (where `T : IEnumerable`) - Static `Deserialize(string json, JsonSerializerOptions? options = null)` methods on all document classes for convenient JSON parsing -- Compile-time type safety and IntelliSense support for JSON:API responses when using generic subclasses +- Extension methods for `HttpResponseMessage.Content` to deserialize JSON:API documents directly from HTTP responses: + - `ReadJsonApiDocumentAsync` + - `ReadJsonApiDocumentAsync` + - `ReadJsonApiCollectionDocumentAsync` ### Removed - **Breaking change:** `GetResource()` method from `JsonApiDocument` (replaced by strongly-typed `JsonApiDocument.Data` property or manual deserialization of `JsonApiDocument.Data`) - **Breaking change:** `GetResourceCollection()` method from `JsonApiDocument` (replaced by strongly-typed `JsonApiCollectionDocument.Data` property or manual deserialization of `JsonApiDocument.Data`) +- **Breaking change:** `HasSingleResource` method from `JsonApiDocument` (just use the inverse of `HasCollectionResource`) ## [2.0.0] - 2025-12-01 diff --git a/CLAUDE.md b/CLAUDE.md index 251dce8..d907388 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ dotnet test --filter "FullyQualifiedName~Crews.Web.JsonApiClient.Tests.Converter ### Run a Single Test Method ```bash -dotnet test --filter "FullyQualifiedName~Crews.Web.JsonApiClient.Tests.JsonApiDocumentTests.HasSingleResourceReturnsTrueForObject" +dotnet test --filter "FullyQualifiedName~Crews.Web.JsonApiClient.Tests.JsonApiDocumentTests.HasCollectionResourceReturnsTrueForArray" ``` ## Architecture Overview @@ -54,9 +54,9 @@ JsonApiDocument (base class) ├── JsonApi (JsonApiInfo?) └── Extensions (Dictionary?) ├── JsonApiDocument - strongly-typed single resource document - │ └── Data (T?) where T : JsonApiResource + │ └── Data (JsonApiResource?) - resource with attributes type T └── JsonApiCollectionDocument - strongly-typed collection document - └── Data (T?) where T : IEnumerable + └── Data (IEnumerable>?) - collection of resources with attributes type T JsonApiResource (extends JsonApiResourceIdentifier) ├── Type/Id/LocalId (identification) @@ -98,7 +98,7 @@ JsonApiLink - `JsonApiDocument` / `JsonApiCollectionDocument` - strongly-typed document data - `JsonApiResource` / `JsonApiResource` - strongly-typed resource attributes and relationships - `JsonApiRelationship` / `JsonApiCollectionRelationship` - strongly-typed relationship data - - `HasSingleResource` / `HasCollectionResource` / `HasErrors` - check document type + - `HasCollectionResource` / `HasErrors` - check document type - Static `Deserialize()` methods on all document types for easy JSON deserialization 2. **Dual-Format Serialization**: `JsonApiLinkConverter` handles JSON:API links, which can be either: @@ -120,7 +120,7 @@ JsonApiLink Raw JSON:API Response ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize()) JsonApiDocument instance (Data as JsonElement) - ↓ (check HasErrors, HasSingleResource, HasCollectionResource) + ↓ (check HasErrors, HasCollectionResource) ↓ (manually deserialize Data property) JsonApiResource object(s) ├── Access Attributes (JsonObject for flexible schema) @@ -132,12 +132,12 @@ JsonApiResource object(s) **Strongly-Typed Approach (compile-time safety):** ``` Raw JSON:API Response - ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize>()) -JsonApiDocument instance (Data as MyResource) + ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize>()) +JsonApiDocument instance (Data as JsonApiResource) ↓ (check HasErrors) -MyResource object (extends JsonApiResource) +JsonApiResource object ├── Access Attributes (MyAttributes with typed properties) - ├── Follow Relationships (MyRelationships with typed JsonApiRelationship properties) + ├── Follow Relationships (Dictionary) ├── Navigate via Links (hypermedia via JsonApiLink) └── Read Metadata (JsonObject) ``` @@ -162,7 +162,7 @@ MyResource object (extends JsonApiResource) - Tests target .NET 10.0 (while library targets .NET 8.0 for compatibility) - Comprehensive test coverage for `JsonApiDocument` including: - All property deserialization and serialization - - Helper methods (`HasSingleResource`, `HasCollectionResource`, `HasErrors`) + - Helper methods (`HasCollectionResource`, `HasErrors`) - Static `Deserialize()` methods on document classes - Generic subclass deserialization for strongly-typed scenarios - Roundtrip serialization tests @@ -182,18 +182,18 @@ Crews.Web.JsonApiClient/ # Main library (.NET 8.0) ├── JsonApiErrorSource.cs # Source pointer for errors ├── JsonApiErrorLinksObject.cs # Links specific to error objects ├── JsonApiInfo.cs # jsonapi member (version, meta) -├── Constants.cs # Media types, parameters, exception messages ├── Converters/ │ └── JsonApiLinkConverter.cs # Custom converter for link string/object duality └── Utility/ + ├── Constants.cs # Media types, parameters, exception messages └── MediaTypeHeaderBuilder.cs # Fluent builder for JSON:API Accept/Content-Type headers Crews.Web.JsonApiClient.Tests/ # Test project (.NET 10.0) -├── JsonApiDocumentTests.cs # Comprehensive tests for JsonApiDocument (31 tests) +├── JsonApiDocumentTests.cs # Comprehensive tests for JsonApiDocument (24 tests) ├── Converters/ -│ └── JsonApiLinkConverterTests.cs # Tests for link converter +│ └── JsonApiLinkConverterTests.cs # Tests for link converter (16 tests) ├── Utility/ -│ └── MediaTypeHeaderBuilderTests.cs # Tests for header builder +│ └── MediaTypeHeaderBuilderTests.cs # Tests for header builder (9 tests) ├── GlobalSuppressions.cs # Code analysis suppressions └── .runsettings # Test execution configuration (LCOV coverage) ``` @@ -211,16 +211,15 @@ Crews.Web.JsonApiClient.Tests/ # Test project (.NET 10.0) ## Current Test Coverage -The library has comprehensive test coverage across all major components: +The library has comprehensive test coverage across all major components (49 total tests): -- **JsonApiDocumentTests.cs**: 31 tests covering all aspects of the document model - - HasSingleResource, HasCollectionResource, HasErrors property tests +- **JsonApiDocumentTests.cs**: 24 tests covering all aspects of the document model + - HasCollectionResource, HasErrors property tests - Static Deserialize() method tests - Property deserialization (JsonApi, Links, Included, Metadata, Errors, Extensions) - Serialization and roundtrip tests for all document types - - **Note**: Tests for generic subclasses (`JsonApiDocument`, `JsonApiCollectionDocument`, `JsonApiResource`, `JsonApiRelationship`) may need to be added -- **JsonApiLinkConverterTests.cs**: Tests for dual-format link serialization -- **MediaTypeHeaderBuilderTests.cs**: Tests for fluent header construction with extensions and profiles +- **JsonApiLinkConverterTests.cs**: 16 tests for dual-format link serialization +- **MediaTypeHeaderBuilderTests.cs**: 9 tests for fluent header construction with extensions and profiles ## Changes in `dev` Branch (vs. `master`) @@ -229,14 +228,14 @@ The `dev` branch introduces **generic subclasses** that enable strongly-typed de ### New Generic Classes 1. **JsonApiDocument** - Strongly-typed single resource document - - `Data` property is typed as `T?` where `T : JsonApiResource` - - Includes static `Deserialize()` method for easy JSON parsing - - Example: `JsonApiDocument.Deserialize(json)` + - `Data` property is typed as `JsonApiResource?` where `T` is the attributes type + - Includes static `Deserialize()` method on base class for easy JSON parsing + - Example: `JsonApiDocument.Deserialize(json)` returns `JsonApiDocument` 2. **JsonApiCollectionDocument** - Strongly-typed collection document - - `Data` property is typed as `T?` where `T : IEnumerable` - - Includes static `Deserialize()` method - - Example: `JsonApiCollectionDocument>.Deserialize(json)` + - `Data` property is typed as `IEnumerable>?` where `T` is the attributes type + - Includes static `DeserializeCollection()` method on base class + - Example: `JsonApiDocument.DeserializeCollection(json)` returns `JsonApiCollectionDocument` 3. **JsonApiResource** - Resource with strongly-typed attributes - `Attributes` property is typed as `T?` instead of `JsonObject?` @@ -262,9 +261,9 @@ The `dev` branch introduces **generic subclasses** that enable strongly-typed de - `GetResourceCollection()` - Previously used to deserialize `Data` as a resource collection **Added Methods**: -- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Static deserialization -- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Strongly-typed static deserialization -- `JsonApiCollectionDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Strongly-typed static deserialization +- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null)` - Static deserialization (weakly-typed) +- `JsonApiDocument.Deserialize(string json, JsonSerializerOptions? options = null) where T : JsonApiResource` - Strongly-typed static deserialization +- `JsonApiDocument.DeserializeCollection(string json, JsonSerializerOptions? options = null)` - Strongly-typed collection deserialization ### Migration Guide (master → dev) @@ -277,7 +276,7 @@ var userName = resource?.Attributes?["userName"]?.GetString(); **After (dev branch - strongly-typed option):** ```csharp -var doc = JsonApiDocument.Deserialize(json); +var doc = JsonApiDocument.Deserialize(json); var userName = doc.Data?.Attributes?.UserName; ``` diff --git a/Crews.Web.JsonApiClient.Tests/HttpResponseMessageExtensionsTests.cs b/Crews.Web.JsonApiClient.Tests/HttpResponseMessageExtensionsTests.cs new file mode 100644 index 0000000..2caf574 --- /dev/null +++ b/Crews.Web.JsonApiClient.Tests/HttpResponseMessageExtensionsTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace Crews.Web.JsonApiClient.Tests; + +public class HttpResponseMessageExtensionsTests +{ + private readonly JsonSerializerOptions _options; + + public HttpResponseMessageExtensionsTests() + { + _options = new JsonSerializerOptions(); + } + + private static HttpResponseMessage CreateResponse(string content) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content, Encoding.UTF8, "application/vnd.api+json") + }; + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync deserializes single resource document")] + public async Task ReadJsonApiDocumentAsyncDeserializesSingleResourceDocument() + { + const string json = """{"data": {"type": "articles", "id": "1"}}"""; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.False(doc.HasCollectionResource); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync deserializes collection resource document")] + public async Task ReadJsonApiDocumentAsyncDeserializesCollectionResourceDocument() + { + const string json = """{"data": [{"type": "articles", "id": "1"}, {"type": "articles", "id": "2"}]}"""; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.True(doc.HasCollectionResource); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync deserializes error document")] + public async Task ReadJsonApiDocumentAsyncDeserializesErrorDocument() + { + const string json = """ + { + "errors": [ + { + "status": "404", + "title": "Not Found", + "detail": "The requested resource was not found." + } + ] + } + """; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.True(doc.HasErrors); + JsonApiError error = doc.Errors!.First(); + Assert.Equal("404", error.StatusCode); + Assert.Equal("Not Found", error.Title); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync generic deserializes strongly-typed document")] + public async Task ReadJsonApiDocumentAsyncGenericDeserializesStronglyTypedDocument() + { + const string json = """ + { + "data": { + "type": "articles", + "id": "1", + "attributes": { + "title": "Test Article" + } + } + } + """; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.NotNull(doc.Data); + Assert.Equal("articles", doc.Data.Type); + Assert.Equal("1", doc.Data.Id); + } + + [Fact(DisplayName = "ReadJsonApiCollectionDocumentAsync deserializes collection document")] + public async Task ReadJsonApiCollectionDocumentAsyncDeserializesCollectionDocument() + { + const string json = """ + { + "data": [ + {"type": "articles", "id": "1"}, + {"type": "articles", "id": "2"}, + {"type": "articles", "id": "3"} + ] + } + """; + using var response = CreateResponse(json); + + JsonApiCollectionDocument? doc = await response.ReadJsonApiCollectionDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.NotNull(doc.Data); + JsonApiResource[] resources = doc.Data.ToArray(); + Assert.Equal(3, resources.Length); + Assert.Equal("1", resources[0].Id); + Assert.Equal("2", resources[1].Id); + Assert.Equal("3", resources[2].Id); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync returns null for null JSON")] + public async Task ReadJsonApiDocumentAsyncReturnsNullForNullJson() + { + const string json = """null"""; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.Null(doc); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync supports cancellation")] + public async Task ReadJsonApiDocumentAsyncSupportsCancellation() + { + const string json = """{"data": {"type": "articles", "id": "1"}}"""; + using var response = CreateResponse(json); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => response.ReadJsonApiDocumentAsync(_options, cts.Token)); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync generic supports cancellation")] + public async Task ReadJsonApiDocumentAsyncGenericSupportsCancellation() + { + const string json = """{"data": {"type": "articles", "id": "1"}}"""; + using var response = CreateResponse(json); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => response.ReadJsonApiDocumentAsync(_options, cts.Token)); + } + + [Fact(DisplayName = "ReadJsonApiCollectionDocumentAsync supports cancellation")] + public async Task ReadJsonApiCollectionDocumentAsyncSupportsCancellation() + { + const string json = """{"data": [{"type": "articles", "id": "1"}]}"""; + using var response = CreateResponse(json); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => response.ReadJsonApiCollectionDocumentAsync(_options, cts.Token)); + } + + [Fact(DisplayName = "ReadJsonApiDocumentAsync deserializes document with all properties")] + public async Task ReadJsonApiDocumentAsyncDeserializesDocumentWithAllProperties() + { + const string json = """ + { + "jsonapi": {"version": "1.1"}, + "data": {"type": "articles", "id": "1"}, + "links": {"self": "https://example.com/articles/1"}, + "meta": {"copyright": "2024"} + } + """; + using var response = CreateResponse(json); + + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(_options); + + Assert.NotNull(doc); + Assert.NotNull(doc.JsonApi); + Assert.Equal("1.1", doc.JsonApi.Version); + Assert.NotNull(doc.Links); + Assert.NotNull(doc.Links.Self); + Assert.NotNull(doc.Metadata); + Assert.Equal("2024", doc.Metadata["copyright"]!.GetValue()); + } +} diff --git a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs index 11a1417..552d851 100644 --- a/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs +++ b/Crews.Web.JsonApiClient.Tests/JsonApiDocumentTests.cs @@ -446,7 +446,7 @@ public void DeserializeStaticMethodReturnsValidDocumentForValidJson() public void DeserializeGenericStaticMethodReturnsNullForNullJson() { const string invalidJson = """null"""; - JsonApiDocument? doc = JsonApiDocument.Deserialize(invalidJson, _options); + JsonApiDocument? doc = JsonApiDocument.Deserialize(invalidJson, _options); Assert.Null(doc); } @@ -454,7 +454,7 @@ public void DeserializeGenericStaticMethodReturnsNullForNullJson() public void DeserializeGenericStaticMethodReturnsValidDocumentForValidJson() { const string validJson = """{"data": {"type": "articles", "id": "1"}}"""; - JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); + JsonApiDocument? doc = JsonApiDocument.Deserialize(validJson, _options); Assert.NotNull(doc); Assert.False(doc.HasCollectionResource); } @@ -463,7 +463,7 @@ public void DeserializeGenericStaticMethodReturnsValidDocumentForValidJson() public void DeserializeCollectionGenericStaticMethodReturnsNullForNullJson() { const string invalidJson = """null"""; - JsonApiCollectionDocument>? doc = JsonApiDocument.DeserializeCollection>(invalidJson, _options); + JsonApiCollectionDocument? doc = JsonApiCollectionDocument.Deserialize(invalidJson, _options); Assert.Null(doc); } @@ -471,10 +471,20 @@ public void DeserializeCollectionGenericStaticMethodReturnsNullForNullJson() public void DeserializeCollectionGenericStaticMethodReturnsValidDocumentForValidJson() { const string validJson = """{"data": [{"type": "articles", "id": "1"}, {"type": "articles", "id": "2"}]}"""; - JsonApiCollectionDocument>? doc = JsonApiDocument.DeserializeCollection>(validJson, _options); + JsonApiCollectionDocument? doc = JsonApiCollectionDocument.Deserialize(validJson, _options); Assert.NotNull(doc); Assert.True(doc.HasCollectionResource); } + public class MyModel + { + public string? Name { get; set; } + public int Age { get; set; } + } + + public class MyModelResource : JsonApiResource { } + + public class MyModelDocument : JsonApiDocument { } + #endregion } diff --git a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj index 2fb6209..a085ee7 100644 --- a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj +++ b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj @@ -28,7 +28,6 @@ - diff --git a/Crews.Web.JsonApiClient/HttpResponseMessageExtensions.cs b/Crews.Web.JsonApiClient/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..40806b7 --- /dev/null +++ b/Crews.Web.JsonApiClient/HttpResponseMessageExtensions.cs @@ -0,0 +1,63 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Crews.Web.JsonApiClient; + +/// +/// Provides extension methods for to deserialize JSON:API documents. +/// +public static class HttpResponseMessageExtensions +{ + /// + /// Deserializes the HTTP response content as a weakly-typed JSON:API document. + /// + /// The HTTP response message. + /// Optional serialization options to control the deserialization behavior. + /// A cancellation token to cancel the operation. + /// + /// A task representing the asynchronous operation. The task result contains a + /// instance, or if the response content is empty or invalid. + /// + public static Task ReadJsonApiDocumentAsync( + this HttpResponseMessage response, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + => response.Content.ReadFromJsonAsync(options, cancellationToken); + + /// + /// Deserializes the HTTP response content as a strongly-typed JSON:API document with a single resource. + /// + /// The resource type, which must inherit from . + /// The HTTP response message. + /// Optional serialization options to control the deserialization behavior. + /// A cancellation token to cancel the operation. + /// + /// A task representing the asynchronous operation. The task result contains a + /// instance, or if the response content is empty or invalid. + /// + public static Task?> ReadJsonApiDocumentAsync( + this HttpResponseMessage response, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + where T : JsonApiResource + => response.Content.ReadFromJsonAsync>(options, cancellationToken); + + /// + /// Deserializes the HTTP response content as a strongly-typed JSON:API document with a resource collection. + /// + /// The resource type, which must inherit from . + /// The HTTP response message. + /// Optional serialization options to control the deserialization behavior. + /// A cancellation token to cancel the operation. + /// + /// A task representing the asynchronous operation. The task result contains a + /// instance, or if the response content is empty or invalid. + /// + public static Task?> ReadJsonApiCollectionDocumentAsync( + this HttpResponseMessage response, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + where T : JsonApiResource + => response.Content.ReadFromJsonAsync>(options, cancellationToken); +} diff --git a/Crews.Web.JsonApiClient/JsonApiDocument.cs b/Crews.Web.JsonApiClient/JsonApiDocument.cs index d682e71..2e25b0b 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -80,35 +80,6 @@ public class JsonApiDocument /// public static JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) => JsonSerializer.Deserialize(json, options); - - /// - /// Deserializes the specified JSON string into a JSON:API document with a user-defined data object. - /// - /// This method uses for deserialization. The input - /// JSON must conform to the JSON:API specification for successful parsing. - /// The JSON string representing a JSON:API document to deserialize. - /// Optional serialization options to control the deserialization behavior. - /// - /// A instance representing the deserialized data, or if - /// the input is invalid or does not match the expected format. - /// - public static JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) - where T : JsonApiResource - => JsonSerializer.Deserialize>(json, options); - - /// - /// Deserializes the specified JSON string into a JSON:API document with a user-defined collection of data objects. - /// - /// The underlying type of each item in the collection. - /// The JSON string representing a JSON:API document to deserialize. - /// Optional serialization options to control the deserialization behavior. - /// - /// A instance representing the deserialized data, or if - /// the input is invalid or does not match the expected format. - /// - public static JsonApiCollectionDocument? DeserializeCollection( - string json, JsonSerializerOptions? options = null) - => JsonSerializer.Deserialize>(json, options); } /// @@ -116,18 +87,32 @@ public class JsonApiDocument /// JSON:API specification. /// /// The underlying resource type. -public class JsonApiDocument : JsonApiDocument +public class JsonApiDocument : JsonApiDocument where T : JsonApiResource { /// /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] - public new JsonApiResource? Data { get; set; } + public new T? Data { get; set; } /// /// Gets a value indicating whether the property contains a single resource object. /// public new bool HasCollectionResource => false; + + /// + /// Deserializes the specified JSON string into a JSON:API document with a user-defined data object. + /// + /// This method uses for deserialization. The input + /// JSON must conform to the JSON:API specification for successful parsing. + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization behavior. + /// + /// A instance representing the deserialized data, or if + /// the input is invalid or does not match the expected format. + /// + public static new JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize>(json, options); } /// @@ -135,16 +120,29 @@ public class JsonApiDocument : JsonApiDocument /// JSON:API specification. /// /// The underlying resource type. -public class JsonApiCollectionDocument : JsonApiDocument +public class JsonApiCollectionDocument : JsonApiDocument where T : JsonApiResource { /// /// Gets or sets the primary data payload associated with the document. /// [JsonPropertyName("data")] - public new IEnumerable>? Data { get; set; } + public new IEnumerable? Data { get; set; } /// /// Gets a value indicating whether the property contains a resource collection object. /// public new bool HasCollectionResource => true; + + /// + /// Deserializes the specified JSON string into a JSON:API document with a user-defined collection of data objects. + /// + /// The JSON string representing a JSON:API document to deserialize. + /// Optional serialization options to control the deserialization behavior. + /// + /// A instance representing the deserialized data, or if + /// the input is invalid or does not match the expected format. + /// + public static new JsonApiCollectionDocument? Deserialize( + string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize>(json, options); } \ No newline at end of file diff --git a/README.md b/README.md index c9a1805..fbc5c6b 100644 --- a/README.md +++ b/README.md @@ -12,73 +12,23 @@ dotnet add package Crews.Web.JsonApiClient ## Quick Start -### Basic Deserialization (Weakly-Typed) - ```csharp -using System.Text.Json; -using Crews.Web.JsonApiClient; - -// Deserialize a JSON:API document -string json = /* your JSON:API document */; -var document = JsonApiDocument.Deserialize(json); - -// Check what type of document you have -if (document.HasErrors) +// Step 1: Define your base model +public class Article { - foreach (var error in document.Errors) - { - Console.WriteLine($"Error {error.Status}: {error.Title}"); - } -} -else if (document.HasSingleResource) -{ - // Manually deserialize the Data property - var resource = document.Data?.Deserialize(); - Console.WriteLine($"Resource: {resource?.Type} with ID {resource?.Id}"); -} -else if (document.HasCollectionResource) -{ - // Manually deserialize the Data property - var resources = document.Data?.Deserialize>(); - Console.WriteLine($"Found {resources?.Count} resources"); -} -``` - -### Strongly-Typed Deserialization - -For better type safety and IntelliSense support, use the generic subclasses: - -```csharp -// Define your strongly-typed resource -public class Article : JsonApiResource -{ -} - -public class ArticleAttributes -{ - [JsonPropertyName("title")] public string? Title { get; set; } - - [JsonPropertyName("body")] public string? Body { get; set; } - - [JsonPropertyName("publishedAt")] public DateTime? PublishedAt { get; set; } } -public class ArticleRelationships -{ - [JsonPropertyName("author")] - public JsonApiRelationship? Author { get; set; } - - [JsonPropertyName("comments")] - public JsonApiCollectionRelationship>? Comments { get; set; } -} +// Step 2: Define a strongly-typed resource class extending JsonApiResource +public class ArticleResource : JsonApiResource
{ } -// Deserialize a single resource document +// Step 3: Deserialize using the static Deserialize() method string json = /* your JSON:API document */; -var document = JsonApiDocument
.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); +// Step 4: Access strongly-typed data with full IntelliSense support! if (document.HasErrors) { foreach (var error in document.Errors) @@ -88,7 +38,7 @@ if (document.HasErrors) } else if (document.Data != null) { - // Data is strongly-typed as Article + // Data is strongly-typed as Article - get full IntelliSense! Console.WriteLine($"Title: {document.Data.Attributes?.Title}"); Console.WriteLine($"Published: {document.Data.Attributes?.PublishedAt}"); @@ -97,8 +47,8 @@ else if (document.Data != null) Console.WriteLine($"Author ID: {authorId}"); } -// Deserialize a collection document -var collection = JsonApiCollectionDocument>.Deserialize(json); +// For collection documents, use JsonApiCollectionDocument +var collection = JsonApiCollectionDocument.Deserialize(json); if (collection.Data != null) { @@ -109,6 +59,41 @@ if (collection.Data != null) } ``` +### Weakly-Typed Deserialization (For Dynamic Schemas) + +If you're working with dynamic or unknown schemas, you can use the weakly-typed base classes: + +```csharp +// Deserialize without custom types +string json = /* your JSON:API document */; +var document = JsonApiDocument.Deserialize(json); + +// Check what type of document you have +if (document.HasErrors) +{ + foreach (var error in document.Errors) + { + Console.WriteLine($"Error {error.Status}: {error.Title}"); + } +} +else if (document.HasCollectionResource) +{ + // Manually deserialize the Data property + var resources = document.Data?.Deserialize>(); + Console.WriteLine($"Found {resources?.Count} resources"); +} +else +{ + // Single resource - manually deserialize the Data property + var resource = document.Data?.Deserialize(); + Console.WriteLine($"Resource: {resource?.Type} with ID {resource?.Id}"); + + // Access attributes dynamically + var title = resource?.Attributes?["title"]?.GetValue(); + Console.WriteLine($"Title: {title}"); +} +``` + ### Complete Real-World Example Here's a complete example showing how to define and use strongly-typed resources: @@ -119,11 +104,9 @@ using System.Text.Json.Serialization; using Crews.Web.JsonApiClient; // Define your resource types -public class User : JsonApiResource -{ -} +public class UserResource : JsonApiResource { } -public class UserAttributes +public class User { [JsonPropertyName("name")] public string? Name { get; set; } @@ -170,7 +153,7 @@ string json = """ } """; -var document = JsonApiDocument.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); // Access with full type safety and IntelliSense if (document.Data != null) @@ -190,99 +173,79 @@ if (document.Data != null) ### Working with Resources -#### Weakly-Typed Approach +#### Strongly-Typed Approach (Recommended) ```csharp -// Deserialize manually from Data property -var resource = document.Data?.Deserialize(); +// Use strongly-typed document with custom resource class +var document = JsonApiDocument.Deserialize(json); // Access resource identification -Console.WriteLine($"Type: {resource?.Type}"); -Console.WriteLine($"ID: {resource?.Id}"); +Console.WriteLine($"Type: {document.Data?.Type}"); +Console.WriteLine($"ID: {document.Data?.Id}"); -// Access attributes (flexible JSON object) -if (resource?.Attributes != null) +// Access strongly-typed attributes with IntelliSense +if (document.Data?.Attributes != null) { - var title = resource.Attributes["title"]?.GetValue(); - var publishedAt = resource.Attributes["publishedAt"]?.GetValue(); + var title = document.Data.Attributes.Title; // Full IntelliSense! + var publishedAt = document.Data.Attributes.PublishedAt; // Strongly-typed! Console.WriteLine($"{title} published at {publishedAt}"); } -// Access metadata -if (resource?.Metadata != null) +// Access metadata (flexible JSON object for extension data) +if (document.Data?.Metadata != null) { - var copyright = resource.Metadata["copyright"]?.GetValue(); + var copyright = document.Data.Metadata["copyright"]?.GetValue(); Console.WriteLine($"Copyright: {copyright}"); } // Navigate links -if (resource?.Links?.Self != null) +if (document.Data?.Links?.Self != null) { - Console.WriteLine($"Self link: {resource.Links.Self.Href}"); + Console.WriteLine($"Self link: {document.Data.Links.Self.Href}"); } ``` -#### Strongly-Typed Approach +#### Weakly-Typed Approach (For Dynamic Schemas) ```csharp -// Use strongly-typed document -var document = JsonApiDocument
.Deserialize(json); +// Deserialize manually from Data property +var document = JsonApiDocument.Deserialize(json); +var resource = document.Data?.Deserialize(); // Access resource identification -Console.WriteLine($"Type: {document.Data?.Type}"); -Console.WriteLine($"ID: {document.Data?.Id}"); +Console.WriteLine($"Type: {resource?.Type}"); +Console.WriteLine($"ID: {resource?.Id}"); -// Access strongly-typed attributes with IntelliSense -if (document.Data?.Attributes != null) +// Access attributes (flexible JSON object) +if (resource?.Attributes != null) { - var title = document.Data.Attributes.Title; - var publishedAt = document.Data.Attributes.PublishedAt; + var title = resource.Attributes["title"]?.GetValue(); + var publishedAt = resource.Attributes["publishedAt"]?.GetValue(); Console.WriteLine($"{title} published at {publishedAt}"); } -// Access metadata (still flexible JSON object) -if (document.Data?.Metadata != null) +// Access metadata +if (resource?.Metadata != null) { - var copyright = document.Data.Metadata["copyright"]?.GetValue(); + var copyright = resource.Metadata["copyright"]?.GetValue(); Console.WriteLine($"Copyright: {copyright}"); } // Navigate links -if (document.Data?.Links?.Self != null) +if (resource?.Links?.Self != null) { - Console.WriteLine($"Self link: {document.Data.Links.Self.Href}"); + Console.WriteLine($"Self link: {resource.Links.Self.Href}"); } ``` ### Working with Relationships -#### Weakly-Typed Approach - -```csharp -var resource = document.Data?.Deserialize(); - -// Access relationships -if (resource?.Relationships != null && - resource.Relationships.TryGetValue("author", out var authorRel)) -{ - // Get related resource identifier - var authorId = authorRel.Data?.Deserialize(); - Console.WriteLine($"Author: {authorId?.Type}/{authorId?.Id}"); - - // Navigate relationship links - if (authorRel.Links?.Related != null) - { - Console.WriteLine($"Fetch author at: {authorRel.Links.Related.Href}"); - } -} -``` - -#### Strongly-Typed Approach +#### Strongly-Typed Approach (Recommended) ```csharp -var document = JsonApiDocument
.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); -// Access strongly-typed relationships +// Access strongly-typed relationships with IntelliSense var authorRel = document.Data?.Relationships?.Author; if (authorRel != null) { @@ -296,7 +259,7 @@ if (authorRel != null) } } -// Access collection relationships +// Access collection relationships (strongly-typed) var commentsRel = document.Data?.Relationships?.Comments; if (commentsRel?.Data != null) { @@ -308,6 +271,28 @@ if (commentsRel?.Data != null) } ``` +#### Weakly-Typed Approach (For Dynamic Schemas) + +```csharp +var document = JsonApiDocument.Deserialize(json); +var resource = document.Data?.Deserialize(); + +// Access relationships dynamically +if (resource?.Relationships != null && + resource.Relationships.TryGetValue("author", out var authorRel)) +{ + // Get related resource identifier + var authorId = authorRel.Data?.Deserialize(); + Console.WriteLine($"Author: {authorId?.Type}/{authorId?.Id}"); + + // Navigate relationship links + if (authorRel.Links?.Related != null) + { + Console.WriteLine($"Fetch author at: {authorRel.Links.Related.Href}"); + } +} +``` + ### Working with Included Resources ```csharp @@ -326,10 +311,42 @@ if (document.Included != null) ### Handling Collections -#### Weakly-Typed Approach +#### Strongly-Typed Approach (Recommended) + +```csharp +// Use strongly-typed collection document with custom resource class +var collection = JsonApiCollectionDocument.Deserialize(json); + +if (collection.Data != null) +{ + foreach (var article in collection.Data) + { + // Access strongly-typed attributes with IntelliSense + Console.WriteLine($"Article: {article.Attributes?.Title}"); + Console.WriteLine($"Published: {article.Attributes?.PublishedAt}"); + + // Access relationships + var authorId = article.Relationships?.Author?.Data?.Id; + Console.WriteLine($"Author ID: {authorId}"); + } +} + +// Access collection-level links (pagination) +if (collection.Links?.Next != null) +{ + Console.WriteLine($"Next page: {collection.Links.Next.Href}"); +} +if (collection.Links?.Prev != null) +{ + Console.WriteLine($"Previous page: {collection.Links.Prev.Href}"); +} +``` + +#### Weakly-Typed Approach (For Dynamic Schemas) ```csharp // Deserialize collection manually +var document = JsonApiDocument.Deserialize(json); var articles = document.Data?.Deserialize>(); if (articles != null) @@ -348,36 +365,57 @@ if (document.Links?.Next != null) } ``` -#### Strongly-Typed Approach +### HTTP Client Integration + +The library provides convenient extension methods for `HttpResponseMessage` that integrate seamlessly with `HttpClient`: ```csharp -// Use strongly-typed collection document -var collection = JsonApiCollectionDocument>.Deserialize(json); +using System.Net.Http; +using System.Net.Http.Headers; +using Crews.Web.JsonApiClient; -if (collection.Data != null) +var client = new HttpClient(); +client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.api+json") +); + +// Strongly-typed collection - ReadJsonApiCollectionDocumentAsync() +var response = await client.GetAsync("https://api.example.com/articles"); +var collection = await response.ReadJsonApiCollectionDocumentAsync(); + +if (collection?.Data != null) { foreach (var article in collection.Data) { - // Access strongly-typed attributes Console.WriteLine($"Article: {article.Attributes?.Title}"); } } -// Access collection-level links -if (collection.Links?.Next != null) +// Strongly-typed single resource - ReadJsonApiDocumentAsync() +var singleResponse = await client.GetAsync("https://api.example.com/articles/123"); +var document = await singleResponse.ReadJsonApiDocumentAsync(); + +Console.WriteLine($"Title: {document?.Data?.Attributes?.Title}"); + +// Weakly-typed - ReadJsonApiDocumentAsync() +var weakResponse = await client.GetAsync("https://api.example.com/unknown"); +var weakDoc = await weakResponse.ReadJsonApiDocumentAsync(); + +if (weakDoc?.HasErrors == true) { - Console.WriteLine($"Next page: {collection.Links.Next.Href}"); + foreach (var error in weakDoc.Errors!) + { + Console.WriteLine($"Error: {error.Title}"); + } } ``` -### HTTP Client Integration +#### Using Custom Headers with Extensions and Profiles ```csharp -using System.Net.Http; -using System.Net.Http.Headers; using Crews.Web.JsonApiClient.Utility; -// Build JSON:API content type header +// Build JSON:API content type header with extensions and profiles var headerBuilder = new MediaTypeHeaderBuilder() .AddExtension(new Uri("https://example.com/ext/atomic")) .AddProfile(new Uri("https://example.com/profiles/flexible-pagination")); @@ -390,15 +428,45 @@ client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(mediaType.MediaType.ToString()) ); -// Make request and deserialize (weakly-typed) +// Make request and deserialize in one step var response = await client.GetAsync("https://api.example.com/articles"); -var json = await response.Content.ReadAsStringAsync(); -var document = JsonApiDocument.Deserialize(json); +var collection = await response.ReadJsonApiCollectionDocumentAsync(); -// Or use strongly-typed deserialization -var typedDocument = JsonApiCollectionDocument>.Deserialize(json); +// Access strongly-typed data +if (collection?.Data != null) +{ + foreach (var article in collection.Data) + { + Console.WriteLine($"Article: {article.Attributes?.Title}"); + } +} ``` +#### Extension Methods Available + +The library provides three extension methods on `HttpResponseMessage`: + +1. **`ReadJsonApiDocumentAsync()`** - Deserializes to a weakly-typed `JsonApiDocument` + ```csharp + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(); + ``` + +2. **`ReadJsonApiDocumentAsync()`** - Deserializes to a strongly-typed `JsonApiDocument` with a single resource + ```csharp + JsonApiDocument? doc = await response.ReadJsonApiDocumentAsync(); + ``` + +3. **`ReadJsonApiCollectionDocumentAsync()`** - Deserializes to a strongly-typed `JsonApiCollectionDocument` with a collection + ```csharp + JsonApiCollectionDocument? collection = + await response.ReadJsonApiCollectionDocumentAsync(); + ``` + +All methods support: +- Optional `JsonSerializerOptions` for custom serialization behavior +- `CancellationToken` for cancellation support +- Automatic error document handling (errors deserialize naturally into `Errors` property) + ### Error Handling ```csharp @@ -479,120 +547,20 @@ var json = JsonSerializer.Serialize(newDocument, new JsonSerializerOptions }); ``` -## When to Use Which Approach - -### Use Strongly-Typed (Generic Subclasses) When: - -- You have a **known, stable schema** for your JSON:API resources -- You want **compile-time type safety** and catch errors early -- You need **IntelliSense/autocomplete** support in your IDE -- You're building a **client for a specific API** with well-defined resource types -- You want **refactoring support** (rename properties, find usages, etc.) - -### Use Weakly-Typed (Base Classes) When: - -- You're working with **dynamic or unknown schemas** -- The API schema **changes frequently** or varies by endpoint -- You're building **generic tooling** that works with any JSON:API endpoint -- You need **maximum flexibility** to handle diverse response structures -- You're **exploring an API** and don't want to define types upfront - -### Mixing Both Approaches - -You can mix both approaches in the same application: - -```csharp -// Use strongly-typed for known resources -var articles = JsonApiCollectionDocument>.Deserialize(articlesJson); - -// Use weakly-typed for dynamic/unknown resources -var unknownDoc = JsonApiDocument.Deserialize(dynamicJson); -var resource = unknownDoc.Data?.Deserialize(); -``` - ## Features -- **Dual typing approach** - Choose between weakly-typed (flexible, schema-agnostic) or strongly-typed (compile-time safety, IntelliSense) deserialization +- **Strongly-typed deserialization** - Define custom `JsonApiResource` classes and get compile-time safety, IntelliSense, and refactoring support +- **HttpClient integration** - Extension methods for `HttpResponseMessage` (`ReadJsonApiDocumentAsync()`, `ReadJsonApiDocumentAsync()`, `ReadJsonApiCollectionDocumentAsync()`) +- **Simple static methods** - Use `JsonApiDocument.Deserialize()` and `JsonApiCollectionDocument.Deserialize()` for easy JSON parsing - **Generic subclasses** for strongly-typed resources, relationships, and documents with full type safety -- **Static deserialization methods** on all document classes for convenient JSON parsing +- **Dual typing approach** - Fall back to weakly-typed base classes for dynamic schemas when needed - **Strongly-typed models** for all JSON:API specification elements -- **Flexible attribute storage** using `JsonObject` for dynamic schemas (or strongly-typed for known schemas) +- **Flexible attribute storage** using `JsonObject` for dynamic schemas or strongly-typed classes for known schemas - **Dual-format link support** (string URLs or rich link objects) - **Extension support** via `[JsonExtensionData]` for custom JSON:API extensions -- **Helper methods** for safe document type checking (`HasErrors`, `HasSingleResource`, `HasCollectionResource`) +- **Helper methods** for safe document type checking (`HasErrors`, `HasCollectionResource`) - **HTTP header utilities** for building spec-compliant Content-Type headers with extensions and profiles - **.NET 8.0 target** with nullable reference types enabled -- **Backward compatible** - existing code continues to work with base classes - -## Migration Guide (v2.0.0 → v3.0.0) - -Version 3.0.0 removes the `GetResource()` and `GetResourceCollection()` methods in favor of strongly-typed generic subclasses and manual deserialization. Here's how to migrate: - -### Before (v2.0.0) - -```csharp -var document = JsonSerializer.Deserialize(json); - -// Get single resource -var resource = document.GetResource(); -var title = resource?.Attributes?["title"]?.GetValue(); - -// Get collection -var resources = document.GetResourceCollection(); -foreach (var resource in resources) -{ - // Process resource -} -``` - -### After (v3.0.0) - Option 1: Weakly-Typed - -```csharp -var document = JsonApiDocument.Deserialize(json); - -// Get single resource -var resource = document.Data?.Deserialize(); -var title = resource?.Attributes?["title"]?.GetValue(); - -// Get collection -var resources = document.Data?.Deserialize>(); -if (resources != null) -{ - foreach (var resource in resources) - { - // Process resource - } -} -``` - -### After (v3.0.0) - Option 2: Strongly-Typed (Recommended) - -```csharp -// Define your types once -public class Article : JsonApiResource -{ -} - -public class ArticleAttributes -{ - [JsonPropertyName("title")] - public string? Title { get; set; } -} - -// Use strongly-typed deserialization -var document = JsonApiDocument
.Deserialize(json); -var title = document.Data?.Attributes?.Title; // Full IntelliSense support! - -// Or for collections -var collection = JsonApiCollectionDocument>.Deserialize(json); -if (collection.Data != null) -{ - foreach (var article in collection.Data) - { - Console.WriteLine(article.Attributes?.Title); - } -} -``` ## Documentation From ff1c771321ad253599a14254c04618ef4c9beb55 Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 16:59:13 -0600 Subject: [PATCH 11/12] Revert readme removal. --- Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj index a085ee7..2fb6209 100644 --- a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj +++ b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj @@ -28,6 +28,7 @@ + From 9da5d4b0807400c62679e8e99e530da73b7118df Mon Sep 17 00:00:00 2001 From: Tommy Crews Date: Mon, 9 Feb 2026 17:00:20 -0600 Subject: [PATCH 12/12] Bump version and update changelog. --- CHANGELOG.md | 4 ++++ Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd6a4d..34cea1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ReadJsonApiDocumentAsync` - `ReadJsonApiCollectionDocumentAsync` +### Changed + +- **Breaking change:** The `Constants` class has been moved to the `Crews.Web.JsonApiClient.Utility` namespace. + ### Removed - **Breaking change:** `GetResource()` method from `JsonApiDocument` (replaced by strongly-typed `JsonApiDocument.Data` property or manual deserialization of `JsonApiDocument.Data`) diff --git a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj index 2fb6209..1c6f76b 100644 --- a/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj +++ b/Crews.Web.JsonApiClient/Crews.Web.JsonApiClient.csproj @@ -9,7 +9,7 @@ Crews.Web.JsonApiClient - 2.0.0 + 3.0.0 Tommy Crews A library containing serialization models and methods for the JSON:API specification.