diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1365705 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)", + "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 f40a8df..34cea1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ 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] - 2026-02-09 + +### 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 +- Extension methods for `HttpResponseMessage.Content` to deserialize JSON:API documents directly from HTTP responses: + - `ReadJsonApiDocumentAsync` + - `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`) +- **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 ### Fixed @@ -15,5 +42,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/CLAUDE.md b/CLAUDE.md index bb88f29..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 @@ -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 (JsonApiResource?) - resource with attributes type T + └── JsonApiCollectionDocument - strongly-typed collection document + └── Data (IEnumerable>?) - collection of resources with attributes type T 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: - - `HasSingleResource` / `HasCollectionResource` / `HasErrors` - check document type - - `GetResource()` / `GetResourceCollection()` - safe deserialization +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 + - `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: - 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 - ↓ (check HasErrors, HasSingleResource, HasCollectionResource) + ↓ (JsonApiDocument.Deserialize() or JsonSerializer.Deserialize()) +JsonApiDocument instance (Data as JsonElement) + ↓ (check HasErrors, 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 JsonApiResource) + ↓ (check HasErrors) +JsonApiResource object + ├── Access Attributes (MyAttributes with typed properties) + ├── Follow Relationships (Dictionary) ├── Navigate via Links (hypermedia via JsonApiLink) └── Read Metadata (JsonObject) ``` @@ -131,8 +162,9 @@ JsonApiResource object(s) - 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`) - - Resource extraction methods (`GetResource()`, `GetResourceCollection()`) + - Helper methods (`HasCollectionResource`, `HasErrors`) + - Static `Deserialize()` methods on document classes + - Generic subclass deserialization for strongly-typed scenarios - Roundtrip serialization tests - Extension data handling @@ -150,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) ``` @@ -179,12 +211,86 @@ 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 - - GetResource() and GetResourceCollection() method 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 -- **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`) + +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 `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 `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?` + - 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 (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) + +**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 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 fe26845..552d851 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 @@ -155,164 +100,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")] @@ -565,9 +352,9 @@ public void RoundtripSerializationPreservesSingleResourceDocument() TestJsonApiDocument? deserialized = JsonSerializer.Deserialize(serialized, _options); Assert.NotNull(deserialized); - Assert.True(deserialized.HasSingleResource); - JsonApiResource? resource = deserialized.GetResource(); - Assert.NotNull(resource); + Assert.False(deserialized.HasCollectionResource); + 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 +382,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 +421,70 @@ 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.False(doc.HasCollectionResource); + } + + [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.False(doc.HasCollectionResource); + } + + [Fact(DisplayName = "DeserializeCollection generic static method returns null for null JSON")] + public void DeserializeCollectionGenericStaticMethodReturnsNullForNullJson() + { + const string invalidJson = """null"""; + JsonApiCollectionDocument? doc = JsonApiCollectionDocument.Deserialize(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 = 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..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. 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 9589b46..2e25b0b 100644 --- a/Crews.Web.JsonApiClient/JsonApiDocument.cs +++ b/Crews.Web.JsonApiClient/JsonApiDocument.cs @@ -53,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. /// @@ -77,36 +68,81 @@ public class JsonApiDocument public bool HasErrors => Errors is not null && Errors.Any(); /// - /// Attempts to deserialize the property as a object. + /// 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. /// - /// The deserialized object if is a valid resource object, or - /// if is . + /// A instance representing the deserialized data, or if the + /// input is invalid or does not match the expected format. /// - /// - public JsonApiResource? GetResource() - { - if (Data is null) return null; - if (Data is JsonElement data && data.ValueKind == JsonValueKind.Object) - return data.Deserialize(); + 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. +/// +/// The underlying resource type. +public class JsonApiDocument : JsonApiDocument where T : JsonApiResource +{ + /// + /// Gets or sets the primary data payload associated with the document. + /// + [JsonPropertyName("data")] + public new T? Data { get; set; } - throw new InvalidOperationException(Constants.Exceptions.GetResourceInvalidType); - } + /// + /// Gets a value indicating whether the property contains a single resource object. + /// + public new bool HasCollectionResource => false; /// - /// Attempts to deserialize the property as a collection of objects. + /// 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. /// - /// The deserialized collection if is a valid resource array, or - /// if is . + /// A instance representing the deserialized data, or if + /// the input is invalid or does not match the expected format. /// - /// - 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); - } + public static new JsonApiDocument? Deserialize(string json, JsonSerializerOptions? options = null) + => JsonSerializer.Deserialize>(json, options); } + +/// +/// 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 underlying resource type. +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; } + + /// + /// 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/Crews.Web.JsonApiClient/JsonApiRelationship.cs b/Crews.Web.JsonApiClient/JsonApiRelationship.cs index 1449adf..5b15ee2 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..a49600b 100644 --- a/Crews.Web.JsonApiClient/JsonApiResource.cs +++ b/Crews.Web.JsonApiClient/JsonApiResource.cs @@ -32,3 +32,30 @@ 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; } +} + +/// +/// 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 +{ + /// + /// 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 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 { diff --git a/README.md b/README.md index d270c6e..fbc5c6b 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,23 @@ dotnet add package Crews.Web.JsonApiClient ## Quick Start -### Basic Deserialization - ```csharp -using System.Text.Json; -using Crews.Web.JsonApiClient; +// Step 1: Define your base model +public class Article +{ + public string? Title { get; set; } + public string? Body { get; set; } + public DateTime? PublishedAt { get; set; } +} -// Deserialize a JSON:API document +// Step 2: Define a strongly-typed resource class extending JsonApiResource +public class ArticleResource : JsonApiResource
{ } + +// Step 3: Deserialize using the static Deserialize() method string json = /* your JSON:API document */; -var document = JsonSerializer.Deserialize(json); +var document = JsonApiDocument.Deserialize(json); -// Check what type of document you have +// Step 4: Access strongly-typed data with full IntelliSense support! if (document.HasErrors) { foreach (var error in document.Errors) @@ -30,28 +36,188 @@ if (document.HasErrors) Console.WriteLine($"Error {error.Status}: {error.Title}"); } } -else if (document.HasSingleResource) +else if (document.Data != null) +{ + // Data is strongly-typed as Article - get full IntelliSense! + 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}"); +} + +// For collection documents, use JsonApiCollectionDocument +var collection = JsonApiCollectionDocument.Deserialize(json); + +if (collection.Data != null) { - var resource = document.GetResource(); - Console.WriteLine($"Resource: {resource.Type} with ID {resource.Id}"); + foreach (var article in collection.Data) + { + Console.WriteLine($"Article: {article.Attributes?.Title}"); + } +} +``` + +### 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) { - 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"); +} +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: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using Crews.Web.JsonApiClient; + +// Define your resource types +public class UserResource : JsonApiResource { } + +public class User +{ + [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 +#### Strongly-Typed Approach (Recommended) + ```csharp +// Use strongly-typed document with custom resource class +var document = JsonApiDocument.Deserialize(json); + // Access resource identification -var resource = document.GetResource(); -Console.WriteLine($"Type: {resource.Type}"); -Console.WriteLine($"ID: {resource.Id}"); +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; // Full IntelliSense! + var publishedAt = document.Data.Attributes.PublishedAt; // Strongly-typed! + Console.WriteLine($"{title} published at {publishedAt}"); +} + +// Access metadata (flexible JSON object for extension data) +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}"); +} +``` + +#### Weakly-Typed Approach (For Dynamic Schemas) + +```csharp +// Deserialize manually from Data property +var document = JsonApiDocument.Deserialize(json); +var resource = document.Data?.Deserialize(); + +// Access resource identification +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,14 +225,14 @@ 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}"); } @@ -74,9 +240,45 @@ if (resource.Links?.Self != null) ### Working with Relationships +#### Strongly-Typed Approach (Recommended) + +```csharp +var document = JsonApiDocument.Deserialize(json); + +// Access strongly-typed relationships with IntelliSense +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 (strongly-typed) +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}"); + } +} +``` + +#### Weakly-Typed Approach (For Dynamic Schemas) + ```csharp -// Access relationships -if (resource.Relationships != null && +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 @@ -109,31 +311,111 @@ if (document.Included != null) ### Handling Collections +#### Strongly-Typed Approach (Recommended) + ```csharp -// Process a collection of resources -var articles = document.GetResourceCollection(); +// Use strongly-typed collection document with custom resource class +var collection = JsonApiCollectionDocument.Deserialize(json); -foreach (var article in articles) +if (collection.Data != null) { - var title = article.Attributes?["title"]?.GetValue(); - Console.WriteLine($"Article: {title}"); + 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 collection-level links - if (document.Links?.Next != null) + // 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) +{ + foreach (var article in articles) { - Console.WriteLine($"Next page: {document.Links.Next.Href}"); + 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}"); +} ``` ### HTTP Client Integration +The library provides convenient extension methods for `HttpResponseMessage` that integrate seamlessly with `HttpClient`: + ```csharp using System.Net.Http; using System.Net.Http.Headers; +using Crews.Web.JsonApiClient; + +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) + { + Console.WriteLine($"Article: {article.Attributes?.Title}"); + } +} + +// 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) +{ + foreach (var error in weakDoc.Errors!) + { + Console.WriteLine($"Error: {error.Title}"); + } +} +``` + +#### Using Custom Headers with Extensions and Profiles + +```csharp 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")); @@ -146,12 +428,45 @@ client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue(mediaType.MediaType.ToString()) ); -// Make request +// 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 = JsonSerializer.Deserialize(json); +var collection = await response.ReadJsonApiCollectionDocumentAsync(); + +// 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 @@ -234,12 +549,17 @@ var json = JsonSerializer.Serialize(newDocument, new JsonSerializerOptions ## Features +- **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 +- **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 +- **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 resource extraction and type checking -- **HTTP header utilities** for building spec-compliant Content-Type headers +- **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 ## Documentation