diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 69adf95..1569580 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -1,30 +1,5 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "properties": { - "Configuration": { - "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", - "enum": [ - "Debug", - "Release" - ] - }, - "GithubToken": { - "type": "string", - "default": "Secrets must be entered via 'nuke :secrets [profile]'" - }, - "NugetApiUrl": { - "type": "string" - }, - "NugetKey": { - "type": "string", - "default": "Secrets must be entered via 'nuke :secrets [profile]'" - }, - "Solution": { - "type": "string", - "description": "Path to a solution file that is automatically loaded" - } - }, "definitions": { "Host": { "type": "string", @@ -127,5 +102,36 @@ } } }, - "$ref": "#/definitions/NukeBuild" + "allOf": [ + { + "properties": { + "Configuration": { + "type": "string", + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", + "enum": [ + "Debug", + "Release" + ] + }, + "GithubToken": { + "type": "string", + "default": "Secrets must be entered via 'nuke :secrets [profile]'" + }, + "NugetApiUrl": { + "type": "string" + }, + "NugetKey": { + "type": "string", + "default": "Secrets must be entered via 'nuke :secrets [profile]'" + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + } + } + }, + { + "$ref": "#/definitions/NukeBuild" + } + ] } diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..0e70c7a --- /dev/null +++ b/AGENT.md @@ -0,0 +1,376 @@ +# AGENT.md + +This document provides guidance for AI agents and developers working on the **Scarlet.System.Text.Json.DateTimeConverter** package. + +## Package Overview + +**Scarlet.System.Text.Json.DateTimeConverter** is a .NET library that provides flexible custom date/time formatting for `System.Text.Json` serialization. It supports both reflection-based and source generator approaches, with special .NET 9+ features for enhanced source generator compatibility. + +### Key Features + +- Custom date/time format attributes (`JsonDateTimeConverterAttribute` for reflection, `JsonDateTimeFormatAttribute` for source generators) +- Source generator-compatible format converters (`JsonDateTimeFormatConverter`) +- .NET 9+ contract customization resolver (`DateTimeConverterResolver`) +- Support for `DateTime`, `DateTimeOffset`, and nullable variants +- Multi-target framework support (.NET 6, 7, 8, 9, 10) + +## Repository Structure + +``` +Scarlet.System.Text.Json.DateTimeConverter/ +├── README.md # User-facing documentation +├── LICENSE # MIT License +├── version.json # Nerdbank.GitVersioning configuration +├── icon.png # Package icon +├── build/ # NUKE build system +│ ├── Build.cs # Main build configuration +│ └── _build.csproj # Build project +├── src/ +│ ├── Scarlet.System.Text.Json.DateTimeConverter/ +│ │ ├── Converters/ # Internal converter implementations +│ │ │ ├── DateTimeConverter.cs +│ │ │ ├── DateTimeNullableConverter.cs +│ │ │ ├── DateTimeOffsetConverter.cs +│ │ │ └── DateTimeOffsetNullableConverter.cs +│ │ ├── DateTimeConverterFactoryHelper.cs +│ │ ├── DateTimeConverterResolver.cs # .NET 9+ contract customization +│ │ ├── IJsonDateTimeFormat.cs +│ │ ├── JsonDateTimeConverterAttribute.cs # For reflection-based serialization +│ │ ├── JsonDateTimeFormatAttribute.cs # For source generators (no warnings) +│ │ ├── JsonDateTimeFormatConverter.cs +│ │ └── Scarlet.System.Text.Json.DateTimeConverter.csproj +│ └── Scarlet.System.Text.Json.DateTimeConverter.Tests/ +│ ├── JsonDateTimeConverterAttributeTests.cs +│ ├── JsonDateTimeFormatConverterTests.cs +│ ├── Model/ # Test models +│ │ ├── ReflectionBasedModel.cs +│ │ ├── SourceGeneratorWithConverterModel.cs +│ │ ├── SourceGeneratorWithResolverAttributeModel.cs # Uses JsonDateTimeConverter (has warnings) +│ │ ├── SourceGeneratorWithResolverFormatModel.cs # Uses JsonDateTimeFormat (no warnings) +│ │ ├── ConverterModelJsonSerializerContext.cs +│ │ └── ResolverModelJsonSerializerContext.cs +│ └── Scarlet.System.Text.Json.DateTimeConverter.Tests.csproj +└── .github/ + └── workflows/ # CI/CD workflows + ├── continuous.yml # Build and test on push + └── release.yml # Publish on tag +``` + +## Building the Project + +### Prerequisites + +- .NET SDK 10.0 or later (for development) +- Git (for version control) + +### Build Commands + +The project uses [NUKE](https://nuke.build/) for build automation. + +#### Quick Build + +```bash +# Linux/macOS +./build.sh Compile + +# Windows +build.cmd Compile +``` + +#### Clean Build + +```bash +# Clean + Restore + Build +./build.sh Clean Compile + +# Or use standard dotnet commands +dotnet clean +dotnet build src/Scarlet.System.Text.Json.DateTimeConverter.sln --configuration Release +``` + +#### Full CI Build + +```bash +./build.sh Clean Restore VerifyFormat Compile Test Pack +``` + +### Build Targets + +| Target | Description | +|--------|-------------| +| `Clean` | Cleans build artifacts | +| `Restore` | Restores NuGet packages | +| `VerifyFormat` | Verifies code formatting (whitespace and style) | +| `Compile` | Compiles the solution | +| `Test` | Runs all tests | +| `Pack` | Creates NuGet packages | +| `Push` | Pushes packages to NuGet.org (requires tag + secrets) | +| `PushGithubNuget` | Pushes packages to GitHub Packages (requires tag + secrets) | + +## Running Tests + +### Run All Tests + +```bash +# Using NUKE +./build.sh Test + +# Using dotnet CLI +dotnet test src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Scarlet.System.Text.Json.DateTimeConverter.Tests.csproj --configuration Release +``` + +### Run Specific Test + +```bash +dotnet test --filter "FullyQualifiedName~ReflectionBased_DateTime_WithAttribute" +``` + +### Test Project Structure + +Tests are organized into two main test classes: + +1. **`JsonDateTimeConverterAttributeTests.cs`** + - Tests for `JsonDateTimeConverterAttribute` with reflection-based serialization + - Tests individual primitive types and complete models + +2. **`JsonDateTimeFormatConverterTests.cs`** + - Tests for `JsonDateTimeFormatConverter` with both reflection and source generators + - Tests for `DateTimeConverterResolver` (.NET 9+) + - Includes tests with null values + +### Test Naming Convention + +Tests follow the pattern: `[ApproachType]_[Scenario]_[OptionalDetails]` + +Examples: +- `ReflectionBased_DateTime_WithAttribute` +- `SourceGenerator_CompleteModel_WithFormatConverter` +- `SourceGenerator_WithResolver_WithAttribute_UsingOptions` + +## Development Workflow + +### 1. Make Changes + +Edit source files in `src/Scarlet.System.Text.Json.DateTimeConverter/` + +### 2. Format Code + +The project enforces code formatting. Use: + +```bash +# Check format +dotnet format whitespace src/Scarlet.System.Text.Json.DateTimeConverter.sln --verify-no-changes +dotnet format style src/Scarlet.System.Text.Json.DateTimeConverter.sln --verify-no-changes + +# Fix format +dotnet format whitespace src/Scarlet.System.Text.Json.DateTimeConverter.sln +dotnet format style src/Scarlet.System.Text.Json.DateTimeConverter.sln +``` + +### 3. Build and Test + +```bash +./build.sh Compile Test +``` + +### 4. Create PR + +The project uses a standard GitHub workflow: +1. Fork or create a branch +2. Make changes +3. Ensure tests pass +4. Create pull request + +## Architecture + +### Core Components + +#### 1. Converters (Internal) + +Four internal converter classes handle actual JSON serialization/deserialization: +- `DateTimeConverter` - for `DateTime` +- `DateTimeNullableConverter` - for `DateTime?` +- `DateTimeOffsetConverter` - for `DateTimeOffset` +- `DateTimeOffsetNullableConverter` - for `DateTimeOffset?` + +All use `CultureInfo.InvariantCulture` for consistent formatting. + +#### 2. Public API + +**`JsonDateTimeConverterAttribute`** +```csharp +[JsonDateTimeConverter("yyyy-MM-dd")] +public DateTime Date { get; set; } +``` +- Derives from `JsonConverterAttribute` +- Works with reflection-based serialization +- .NET 9+: Works with source generators via `DateTimeConverterResolver` but produces SYSLIB1223 warnings + +**`JsonDateTimeFormatAttribute` (.NET 9+)** +```csharp +[JsonDateTimeFormat("yyyy-MM-dd")] +public DateTime Date { get; set; } +``` +- Derives from `Attribute` (not `JsonConverterAttribute`) +- Designed for use with .NET 9+ source generators and `DateTimeConverterResolver` +- **No SYSLIB1223 warnings** (recommended over `JsonDateTimeConverterAttribute` for source generators) + +**`JsonDateTimeFormatConverter`** +```csharp +[JsonConverter(typeof(JsonDateTimeFormatConverter))] +public DateTime Date { get; set; } +``` +- `JsonConverterFactory` implementation +- Compatible with source generators (all .NET versions) +- Requires `IJsonDateTimeFormat` implementation + +**`DateTimeConverterResolver` (.NET 9+)** +```csharp +var options = new JsonSerializerOptions +{ + TypeInfoResolver = new DateTimeConverterResolver(MyJsonContext.Default) +}; +``` +- Implements `IJsonTypeInfoResolver` and extends `JsonSerializerContext` +- Uses `JsonPropertyInfo.AttributeProvider` (populated by source generators in .NET 9+) to read attributes +- Supports both `JsonDateTimeFormatAttribute` (no warnings) and `JsonDateTimeConverterAttribute` (backward compatibility) +- Enables attribute syntax with source generators + +#### 3. Factory Helper + +`DateTimeConverterFactoryHelper` centralizes converter instantiation based on target type. + +### Multi-Targeting + +The library targets multiple frameworks to maximize compatibility: + +```xml +net6.0;net7.0;net8.0;net9.0;net10.0 +``` + +`DateTimeConverterResolver` is conditionally compiled for .NET 9+ only: + +```csharp +#if NET9_0_OR_GREATER +public class DateTimeConverterResolver : JsonSerializerContext, IJsonTypeInfoResolver +{ + // Implementation +} +#endif +``` + +### Testing Strategy + +Tests verify four distinct usage patterns: + +1. **Reflection-based** - Uses `JsonDateTimeConverterAttribute` with default `JsonSerializer` +2. **Source generator with converter** - Uses `JsonDateTimeFormatConverter` with `JsonSerializerContext` +3. **Source generator with resolver (new attribute)** - Uses `JsonDateTimeFormatAttribute` + `DateTimeConverterResolver` (.NET 9+, no warnings) +4. **Source generator with resolver (old attribute)** - Uses `JsonDateTimeConverterAttribute` + `DateTimeConverterResolver` (.NET 9+, with SYSLIB1223 warnings for backward compatibility) + +Each pattern is tested with: +- Individual types (`DateTime`, `DateTime?`, `DateTimeOffset`, `DateTimeOffset?`) +- Complete models +- Null value handling + +## Common Tasks + +### Adding a New Converter + +1. Create converter in `Converters/` directory +2. Implement `JsonConverter` with `Read` and `Write` methods +3. Register in `DateTimeConverterFactoryHelper.CreateConverter` +4. Add tests in both test files +5. Update documentation + +### Updating Supported Frameworks + +1. Update `` in `.csproj` +2. Test all scenarios on new framework +3. Update `README.md` prerequisites +4. Update CI/CD workflows if needed + +### Releasing a New Version + +Versioning is handled by [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning): + +1. Update `version.json` if needed +2. Create a git tag: `git tag 1.2.0` +3. Push tag: `git push origin 1.2.0` +4. GitHub Actions will automatically build and publish + +## Troubleshooting + +### Build Issues + +**Problem:** "Shallow clone lacks the objects required to calculate version height" + +**Solution:** +```bash +git fetch --unshallow +``` + +**Problem:** Format verification fails + +**Solution:** +```bash +dotnet format whitespace src/Scarlet.System.Text.Json.DateTimeConverter.sln +dotnet format style src/Scarlet.System.Text.Json.DateTimeConverter.sln +``` + +### Test Issues + +**Problem:** SYSLIB1223 warning in tests + +**Solution:** This is expected for `SourceGeneratorWithResolverModel` as it demonstrates the problem that `DateTimeConverterResolver` solves. + +**Problem:** Tests fail after renaming models + +**Solution:** Ensure all references are updated in both test files and `TestModelSourceGeneratorJsonSerializerContext.cs`. + +## CI/CD + +### Continuous Integration + +On every push: +- Restore packages +- Verify code format +- Compile all target frameworks +- Run tests +- Create NuGet packages (artifacts) + +### Release + +On tag push (e.g., `1.2.0`): +- All CI steps +- Publish to NuGet.org +- Publish to GitHub Packages + +### Secrets Required + +- `NUGET_KEY` - NuGet.org API key +- `GITHUB_TOKEN` - Automatically provided by GitHub Actions + +## Code Style + +- Use C# 12+ features where appropriate +- Nullable reference types enabled +- `ImplicitUsings` enabled +- Follow .editorconfig rules +- Use XML documentation for public APIs +- Keep internal converters simple and focused + +## Performance Considerations + +- Converters use `CultureInfo.InvariantCulture` for consistent, culture-independent formatting +- No reflection in hot path (converters are created once and reused) +- Source generator support ensures zero reflection overhead when using AOT + +## Additional Resources + +- [System.Text.Json Documentation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview) +- [Custom Converters](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to) +- [Source Generation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation) +- [Contract Customization (.NET 9+)](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/custom-contracts) +- [NUKE Build](https://nuke.build/) +- [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) diff --git a/README.md b/README.md index 123901f..4630b2c 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,449 @@ -## Overview +# Scarlet.System.Text.Json.DateTimeConverter + [![Nuget](https://img.shields.io/nuget/v/Scarlet.System.Text.Json.DateTimeConverter?color=ff4081&logo=nuget)](https://www.nuget.org/packages/Scarlet.System.Text.Json.DateTimeConverter) [![Nuget](https://img.shields.io/nuget/dt/Scarlet.System.Text.Json.DateTimeConverter?color=ff4081&label=nuget%20downloads&logo=nuget)](https://www.nuget.org/packages/Scarlet.System.Text.Json.DateTimeConverter) [![GitHub](https://img.shields.io/github/license/ScarletKuro/Scarlet.System.Text.Json.DateTimeConverter?color=594ae2&logo=github)](https://github.com/ScarletKuro/Scarlet.System.Text.Json.DateTimeConverter/blob/master/LICENSE) -This package allows you to specify a custom date format for `DateTime`, `DateTimeOffset`, and their nullable counterparts when serializing and deserializing JSON using `System.Text.Json`. +A flexible and powerful library for customizing `DateTime` and `DateTimeOffset` serialization in System.Text.Json, with full support for both reflection-based and source generator approaches. -## Installation +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Usage Scenarios](#usage-scenarios) + - [Reflection-Based Serialization (.NET 6+)](#reflection-based-serialization-net-6) + - [Source Generator with Format Converter (.NET 6+)](#source-generator-with-format-converter-net-6) + - [Source Generator with Attribute and Resolver (.NET 9+)](#source-generator-with-attribute-and-resolver-net-9) +- [Available Components](#available-components) +- [When to Use What](#when-to-use-what) +- [Quirks and Limitations](#quirks-and-limitations) +- [Supported Types](#supported-types) +- [License](#license) + +## Overview -To install the **Scarlet.System.Text.Json.DateTimeConverter** package, run the following command in your terminal: +This package provides four ways to specify custom date formats for `DateTime`, `DateTimeOffset`, and their nullable counterparts when serializing and deserializing JSON using `System.Text.Json`: + +1. **`JsonDateTimeConverterAttribute`** - Simple attribute-based approach (reflection only, or .NET 9+ with resolver but produces warnings) +2. **`JsonDateTimeFormatAttribute`** - Clean attribute for source generators with .NET 9+ resolver (no warnings) +3. **`JsonDateTimeFormatConverter`** - Type-safe converter for source generators (all .NET versions) +4. **`DateTimeConverterResolver`** - Contract customization for .NET 9+ source generators + +## Installation ```bash dotnet add package Scarlet.System.Text.Json.DateTimeConverter ``` -### Prerequisites +## Prerequisites -Make sure you have the appropriate .NET target framework installed. This package is compatible with the following versions: +- **.NET 6+** for basic functionality +- **.NET 9+** for `DateTimeConverterResolver` (source generator attribute support) -- .NET 6 -- .NET 7 -- .NET 8 +| Target Framework | Reflection + Attribute | Source Generator + Converter | Source Generator + Attribute + Resolver | +|-----------------|:---------------------:|:---------------------------:|:--------------------------------------:| +| .NET 6, 7, 8 | ✅ | ✅ | ❌ | +| .NET 9, 10+ | ✅ | ✅ | ✅ | -## Usage +## Quick Start -Examples of how to serialize and deserialize models with custom date formats using `JsonDateTimeConverter` attribute and `JsonDateTimeFormatConverter` converter. +**Simplest approach** (reflection-based): -**Note:** The `JsonDateTimeConverter` attribute does not support `System.Text.Json` source generators. Using this attribute with `JsonSerializerContext` results in **SYSLIB1223**: "Attributes deriving from `JsonConverterAttribute` are not supported by the source generator." - -In such cases, use the `JsonDateTimeFormatConverter`, which also works with reflection-based serialization and deserialization. The `JsonDateTimeConverter` attribute is simply less verbose and more readable than the `JsonDateTimeFormatConverter`. +```csharp +public class MyModel +{ + [JsonDateTimeConverter("yyyy-MM-dd")] + public DateTime Date { get; set; } +} -### Using reflection based only with `JsonDateTimeConverter` +var json = JsonSerializer.Serialize(new MyModel { Date = DateTime.Now }); +// Output: {"Date":"2026-01-15"} +``` -This will work only with reflection-based serialization and deserialization. +**Best for source generators** (.NET 9+, no warnings): ```csharp public class MyModel { - [JsonDateTimeConverter("yyyy-MM-dd")] + [JsonDateTimeFormat("yyyy-MM-dd")] public DateTime Date { get; set; } +} + +[JsonSerializable(typeof(MyModel))] +public partial class MyJsonContext : JsonSerializerContext { } + +var options = new JsonSerializerOptions +{ + TypeInfoResolver = new DateTimeConverterResolver(MyJsonContext.Default) +}; +var json = JsonSerializer.Serialize(new MyModel { Date = DateTime.Now }, options); +// Output: {"Date":"2026-01-15"} +``` + +## Usage Scenarios + +### Reflection-Based Serialization (.NET 6+) + +Use `JsonDateTimeConverterAttribute` for the simplest, most readable approach with reflection-based serialization. + +```csharp +using Scarlet.System.Text.Json.DateTimeConverter; +using System.Text.Json; + +public class Order +{ + [JsonDateTimeConverter("yyyy-MM-dd")] + public DateTime OrderDate { get; set; } + + [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss")] + public DateTime? ProcessedDate { get; set; } [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss.fffZ")] - public DateTimeOffset DateTimeOffset { get; set; } + public DateTimeOffset ShippedAt { get; set; } } -public class Program +// Usage +var order = new Order { - public static void Main() - { - var model = new MyModel - { - Date = DateTime.Now, - DateTimeOffset = DateTimeOffset.Now - }; - - // Serialize - string jsonString = JsonSerializer.Serialize(model); - Console.WriteLine($"Serialized JSON: {jsonString}"); - - // Deserialize - var deserializedModel = JsonSerializer.Deserialize(jsonString); - Console.WriteLine($"Deserialized Date: {deserializedModel.Date}"); - Console.WriteLine($"Deserialized DateTimeOffset: {deserializedModel.DateTimeOffset}"); - } -} + OrderDate = new DateTime(2026, 1, 15), + ProcessedDate = new DateTime(2026, 1, 15, 14, 30, 0), + ShippedAt = DateTimeOffset.UtcNow +}; + +string json = JsonSerializer.Serialize(order); +Console.WriteLine(json); +// Output: {"OrderDate":"2026-01-15","ProcessedDate":"2026-01-15T14:30:00","ShippedAt":"2026-01-15T14:30:00.123Z"} + +var deserializedOrder = JsonSerializer.Deserialize(json); ``` -### Using Source Generators with `JsonDateTimeFormatConverter` +**✅ Pros:** +- Clean, readable code with attribute decoration +- Works with all .NET versions (6+) +- Easy to use and understand + +**❌ Cons:** +- Only works with reflection-based serialization +- Produces SYSLIB1223 warning with source generators (.NET 6-8) +- No AOT (Ahead-of-Time) compilation support + +--- -To work with `System.Text.Json` source generators, use `JsonDateTimeFormatConverter` instead of `JsonDateTimeConverterAttribute`. This can also work with reflection-based serialization and deserialization. +### Source Generator with Format Converter (.NET 6+) +Use `JsonDateTimeFormatConverter` for source generator compatibility across all .NET versions. ```csharp -public class MyModelSourceGenerator +using Scarlet.System.Text.Json.DateTimeConverter; +using System.Text.Json; +using System.Text.Json.Serialization; + +public class Order { - [JsonConverter(typeof(JsonDateTimeFormatConverter))] - public DateTime Date { get; set; } + [JsonConverter(typeof(JsonDateTimeFormatConverter))] + public DateTime OrderDate { get; set; } - [JsonConverter(typeof(JsonDateTimeFormatConverter))] - public DateTimeOffset DateTimeOffset { get; set; } + [JsonConverter(typeof(JsonDateTimeFormatConverter))] + public DateTime? ProcessedDate { get; set; } + + [JsonConverter(typeof(JsonDateTimeFormatConverter))] + public DateTimeOffset ShippedAt { get; set; } } -internal class JsonDateTimeFormat +// Define your custom date formats +public static class DateFormats { - internal class DateTimeOffsetFormat : IJsonDateTimeFormat + public class DateOnly : IJsonDateTimeFormat { - public static string Format => "yyyy-MM-ddTHH:mm:ss.fffZ"; + public static string Format => "yyyy-MM-dd"; } - - internal class DateTimeFormat : IJsonDateTimeFormat + + public class DateTimeSeconds : IJsonDateTimeFormat { public static string Format => "yyyy-MM-ddTHH:mm:ss"; } + + public class ISO8601 : IJsonDateTimeFormat + { + public static string Format => "yyyy-MM-ddTHH:mm:ss.fffZ"; + } } -[JsonSerializable(typeof(MyModelSourceGenerator))] -public sealed partial class MyModelSourceGeneratorJsonSerializerContext : JsonSerializerContext; +// Create a JsonSerializerContext for source generation +[JsonSerializable(typeof(Order))] +[JsonSourceGenerationOptions(WriteIndented = true)] +public partial class OrderJsonContext : JsonSerializerContext { } -public class Program +// Usage with source generator +var order = new Order { - public static void Main() - { - var modelType = typeof(MyModelSourceGenerator); - var model = new MyModelSourceGenerator - { - Date = DateTime.Now, - DateTimeOffset = DateTimeOffset.Now - }; - - var context = MyModelSourceGeneratorJsonSerializerContext.Default; - - // Serialize - string jsonString = JsonSerializer.Serialize(model, modelType, context); - Console.WriteLine($"Serialized JSON: {jsonString}"); - - // Deserialize - var deserializedModel = (MyModelSourceGenerator?)JsonSerializer.Deserialize(jsonString, modelType, context); - Console.WriteLine($"Deserialized Date: {deserializedModel.Date}"); - Console.WriteLine($"Deserialized DateTimeOffset: {deserializedModel.DateTimeOffset}"); - } + OrderDate = new DateTime(2026, 1, 15), + ProcessedDate = new DateTime(2026, 1, 15, 14, 30, 0), + ShippedAt = DateTimeOffset.UtcNow +}; + +string json = JsonSerializer.Serialize(order, typeof(Order), OrderJsonContext.Default); +Console.WriteLine(json); + +var deserializedOrder = (Order?)JsonSerializer.Deserialize(json, typeof(Order), OrderJsonContext.Default); +``` + +**✅ Pros:** +- Works with source generators (AOT-friendly) +- Compatible with all .NET versions (6+) +- Type-safe format definitions + +**❌ Cons:** +- Requires defining a class for each date format +- More verbose than attribute-based approach +- Format classes add boilerplate code + +--- + +### Source Generator with Resolver (.NET 9+) + +**.NET 9+** populates `JsonPropertyInfo.AttributeProvider` in source generators, enabling attribute-based syntax with `DateTimeConverterResolver`. + +#### Option A: JsonDateTimeFormatAttribute (Recommended - No Warnings) + +Use `JsonDateTimeFormatAttribute` for the cleanest experience without SYSLIB1223 warnings: + +```csharp +using Scarlet.System.Text.Json.DateTimeConverter; +using System.Text.Json; +using System.Text.Json.Serialization; + +public class Order +{ + [JsonDateTimeFormat("yyyy-MM-dd")] + public DateTime OrderDate { get; set; } + + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss")] + public DateTime? ProcessedDate { get; set; } + + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss.fffZ")] + public DateTimeOffset ShippedAt { get; set; } +} + +[JsonSerializable(typeof(Order))] +[JsonSourceGenerationOptions(WriteIndented = true)] +public partial class OrderJsonContext : JsonSerializerContext { } + +// Usage - Method 1: With JsonSerializerOptions +var options = new JsonSerializerOptions +{ + WriteIndented = true, + TypeInfoResolver = new DateTimeConverterResolver(OrderJsonContext.Default) +}; + +string json = JsonSerializer.Serialize(order, options); +var deserializedOrder = JsonSerializer.Deserialize(json, options); + +// Usage - Method 2: With Context directly +var resolver = new DateTimeConverterResolver(OrderJsonContext.Default); +string json = JsonSerializer.Serialize(order, typeof(Order), resolver); +var deserializedOrder = (Order?)JsonSerializer.Deserialize(json, typeof(Order), resolver); +``` + +**✅ Pros:** +- Clean attribute syntax with source generators +- AOT-friendly +- **No SYSLIB1223 warnings** +- Best of both worlds: readability + performance + +**❌ Cons:** +- **Requires .NET 9+** +- Slightly more setup (need to wrap context with resolver) + +#### Option B: JsonDateTimeConverterAttribute (Backward Compatible - Has Warnings) + +You can also use `JsonDateTimeConverterAttribute` (for backward compatibility), but it will produce SYSLIB1223 warnings: + +```csharp +public class Order +{ + [JsonDateTimeConverter("yyyy-MM-dd")] // ⚠️ Produces SYSLIB1223 warning + public DateTime OrderDate { get; set; } +} +``` + +The resolver still works, but the source generator will emit warnings because `JsonDateTimeConverterAttribute` derives from `JsonConverterAttribute`. + +**✅ Pros:** +- Works with existing code using `JsonDateTimeConverterAttribute` +- Backward compatible + +**❌ Cons:** +- Produces SYSLIB1223 warnings during build +- May confuse users about the warnings + +--- + +## Available Components + +### `JsonDateTimeConverterAttribute` + +A `JsonConverterAttribute`-derived attribute for specifying date formats directly on properties. + +```csharp +[JsonDateTimeConverter("yyyy-MM-dd")] +public DateTime Date { get; set; } +``` + +**When to use:** Reflection-based serialization. Can also be used with .NET 9+ `DateTimeConverterResolver` but produces SYSLIB1223 warnings. + +--- + +### `JsonDateTimeFormatAttribute` (.NET 9+) + +A simple `Attribute`-derived attribute for specifying date formats with source generators (no warnings). + +```csharp +[JsonDateTimeFormat("yyyy-MM-dd")] +public DateTime Date { get; set; } +``` + +**When to use:** .NET 9+ source generators with `DateTimeConverterResolver` (recommended, no warnings). + +--- + +### `JsonDateTimeFormatConverter` + +A `JsonConverterFactory` that uses `IJsonDateTimeFormat` implementations to define formats. + +```csharp +public class MyFormat : IJsonDateTimeFormat +{ + public static string Format => "yyyy-MM-dd"; } + +[JsonConverter(typeof(JsonDateTimeFormatConverter))] +public DateTime Date { get; set; } +``` + +**When to use:** Source generators on any .NET version (6+). + +--- + +### `DateTimeConverterResolver` (.NET 9+) + +A `JsonSerializerContext` and `IJsonTypeInfoResolver` that enables attribute-based date formatting with source generators by using contract customization. + +```csharp +var resolver = new DateTimeConverterResolver(MyJsonContext.Default); +var options = new JsonSerializerOptions { TypeInfoResolver = resolver }; ``` -Unfortunately, there is no better way with the source generator than defining a class for each date-time format. This is because the `JsonConverterAttribute` is not supported by the source generator, and neither `JsonConverterFactory` nor `JsonConverter` allows passing the format string to the converter, as they lack constructors with parameters. -The new contract customization does not provide attribute support for the source generator as well. +**When to use:** .NET 9+ source generators with `JsonDateTimeFormatAttribute` or `JsonDateTimeConverterAttribute`. + +--- + +## When to Use What + +| Scenario | Recommended Approach | +|----------|---------------------| +| Reflection-based, any .NET version | `JsonDateTimeConverterAttribute` | +| Source generator, .NET 6-8 | `JsonDateTimeFormatConverter` | +| Source generator, .NET 9+ (no warnings) | `JsonDateTimeFormatAttribute` + `DateTimeConverterResolver` | +| Source generator, .NET 9+ (backward compat) | `JsonDateTimeConverterAttribute` + `DateTimeConverterResolver` (⚠️ warnings) | +| Need reusable formats across many properties | `JsonDateTimeFormatConverter` (define format class once) | +| Prototyping/simple projects | `JsonDateTimeConverterAttribute` (simplest) | +| AOT compilation | `JsonDateTimeFormatConverter` or .NET 9+ resolver with attributes | + +--- + +## Quirks and Limitations + +### Source Generator Limitations (.NET 6-8) + +`JsonDateTimeConverterAttribute` produces **SYSLIB1223** warning with source generators in .NET 6-8: + +> "Attributes deriving from JsonConverterAttribute are not supported by the source generator." + +**Solution:** Use `JsonDateTimeFormatConverter` instead, or upgrade to .NET 9+ and use `JsonDateTimeFormatAttribute` with `DateTimeConverterResolver` (no warnings). + +--- + +### SYSLIB1223 Warning with JsonDateTimeConverterAttribute (.NET 9+ Source Generators) + +When using `JsonDateTimeConverterAttribute` with source generators in .NET 9+, you'll get SYSLIB1223 warnings: + +> "Attributes deriving from JsonConverterAttribute are not supported by the source generator." + +This happens because `JsonDateTimeConverterAttribute` derives from `JsonConverterAttribute`. The code still works with `DateTimeConverterResolver`, but the warnings may be confusing. + +**Solution:** Use `JsonDateTimeFormatAttribute` instead, which derives only from `Attribute` and produces no warnings: + +```csharp +// ❌ Produces warnings (but still works) +[JsonDateTimeConverter("yyyy-MM-dd")] +public DateTime Date { get; set; } + +// ✅ No warnings +[JsonDateTimeFormat("yyyy-MM-dd")] +public DateTime Date { get; set; } +``` + +--- + +### Format Class Per Format + +With `JsonDateTimeFormatConverter`, you need one class per unique format: + +```csharp +public class Format1 : IJsonDateTimeFormat { public static string Format => "yyyy-MM-dd"; } +public class Format2 : IJsonDateTimeFormat { public static string Format => "yyyy-MM-ddTHH:mm:ss"; } +``` + +This is a limitation of source generators not supporting constructor parameters or static analyzer tricks. + +--- + +### .NET 9+ Resolver Requirement + +`DateTimeConverterResolver` **only works on .NET 9+** because, while `JsonPropertyInfo.AttributeProvider` exists in .NET 7-8, it is not populated by source generators until .NET 9+. See [runtime#100095](https://github.com/dotnet/runtime/issues/100095) and [runtime#102078](https://github.com/dotnet/runtime/issues/102078) for details. + +--- + +### Null Handling + +Nullable types (`DateTime?`, `DateTimeOffset?`) write `null` in JSON when the value is `null`: + +```json +{ + "NullableDate": null +} +``` + +This matches standard `System.Text.Json` behavior. + +--- + +## Supported Types + +- `DateTime` +- `DateTime?` +- `DateTimeOffset` +- `DateTimeOffset?` + +All types support any valid [.NET date and time format string](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). + +--- + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +## Contributing + +Contributions are welcome! Please open an issue or pull request on [GitHub](https://github.com/ScarletKuro/Scarlet.System.Text.Json.DateTimeConverter). + +--- -## Notes +## Support -- The `JsonDateTimeConverterAttribute` and `JsonDateTimeFormatConverter` can be applied to properties of type `DateTime`, `DateTime?`, `DateTimeOffset`, and `DateTimeOffset?`. -- The format string provided to the attribute should follow the standard date and time format strings in .NET. +If you encounter any issues or have questions, please open an issue on the [GitHub repository](https://github.com/ScarletKuro/Scarlet.System.Text.Json.DateTimeConverter/issues). diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeConverterAttributeTests.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeConverterAttributeTests.cs index 0d9690c..3e389ca 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeConverterAttributeTests.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeConverterAttributeTests.cs @@ -6,7 +6,7 @@ namespace Scarlet.System.Text.Json.DateTimeConverter.Tests; public class JsonDateTimeConverterAttributeTests { [Fact] - public void SerializeAndDeserialize_DateTime_ShouldMatchOriginal() + public void ReflectionBased_DateTime_WithAttribute() { // Arrange var options = new JsonSerializerOptions @@ -25,7 +25,7 @@ public void SerializeAndDeserialize_DateTime_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_NullableDateTime_ShouldMatchOriginal() + public void ReflectionBased_NullableDateTime_WithAttribute() { // Arrange var options = new JsonSerializerOptions @@ -44,7 +44,7 @@ public void SerializeAndDeserialize_NullableDateTime_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_DateTimeOffset_ShouldMatchOriginal() + public void ReflectionBased_DateTimeOffset_WithAttribute() { // Arrange var options = new JsonSerializerOptions @@ -63,7 +63,7 @@ public void SerializeAndDeserialize_DateTimeOffset_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_NullableDateTimeOffset_ShouldMatchOriginal() + public void ReflectionBased_NullableDateTimeOffset_WithAttribute() { // Arrange var options = new JsonSerializerOptions @@ -82,14 +82,14 @@ public void SerializeAndDeserialize_NullableDateTimeOffset_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_TestModel_ShouldMatchOriginal() + public void ReflectionBased_CompleteModel_WithAttribute() { // Arrange var options = new JsonSerializerOptions { WriteIndented = true }; - var originalModel = new TestModelJsonDateTimeConverter + var originalModel = new ReflectionBasedModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), @@ -107,7 +107,7 @@ public void SerializeAndDeserialize_TestModel_ShouldMatchOriginal() // Act var json = JsonSerializer.Serialize(originalModel, options); - var deserializedModel = JsonSerializer.Deserialize(json, options); + var deserializedModel = JsonSerializer.Deserialize(json, options); // Assert Assert.NotNull(deserializedModel); @@ -119,14 +119,14 @@ public void SerializeAndDeserialize_TestModel_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_TestModel_WithNullValues_ShouldMatchOriginal() + public void ReflectionBased_CompleteModel_WithAttribute_WithNullValues() { // Arrange var options = new JsonSerializerOptions { WriteIndented = true }; - var originalModel = new TestModelJsonDateTimeConverter + var originalModel = new ReflectionBasedModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = null, @@ -144,7 +144,7 @@ public void SerializeAndDeserialize_TestModel_WithNullValues_ShouldMatchOriginal // Act var json = JsonSerializer.Serialize(originalModel, options); - var deserializedModel = JsonSerializer.Deserialize(json, options); + var deserializedModel = JsonSerializer.Deserialize(json, options); // Assert Assert.NotNull(deserializedModel); @@ -154,4 +154,150 @@ public void SerializeAndDeserialize_TestModel_WithNullValues_ShouldMatchOriginal Assert.Null(deserializedModel.NullableDateTimeOffsetProperty); Assert.Equal(expectedJson, json); } + + [Fact] + public void SourceGenerator_WithResolver_WithFormatAttribute_UsingOptions() + { + // Arrange + var sourceGenOptions = new JsonSerializerOptions + { + WriteIndented = true, + TypeInfoResolver = new DateTimeConverterResolver(ResolverModelJsonSerializerContext.Default) + }; + var originalModel = new SourceGeneratorWithResolverFormatModel + { + DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), + NullableDateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero) + }; + const string expectedJson = """ + { + "DateTimeProperty": "2023-10-01T12:00:00", + "NullableDateTimeProperty": "2023-10-01T12:00:00", + "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", + "NullableDateTimeOffsetProperty": "2023-10-01T12:00:00.000Z" + } + """; + + // Act + var json = JsonSerializer.Serialize(originalModel, sourceGenOptions); + var deserializedModel = JsonSerializer.Deserialize(json, sourceGenOptions); + + // Assert + Assert.NotNull(deserializedModel); + Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); + Assert.Equal(originalModel.NullableDateTimeProperty, deserializedModel.NullableDateTimeProperty); + Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); + Assert.Equal(originalModel.NullableDateTimeOffsetProperty, deserializedModel.NullableDateTimeOffsetProperty); + Assert.Equal(expectedJson, json); + } + + [Fact] + public void SourceGenerator_WithResolver_WithFormatAttribute_WithNullValues_UsingOptions() + { + // Arrange + var sourceGenOptions = new JsonSerializerOptions + { + WriteIndented = true, + TypeInfoResolver = new DateTimeConverterResolver(ResolverModelJsonSerializerContext.Default) + }; + var originalModel = new SourceGeneratorWithResolverFormatModel + { + DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + NullableDateTimeProperty = null, + DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), + NullableDateTimeOffsetProperty = null + }; + const string expectedJson = """ + { + "DateTimeProperty": "2023-10-01T12:00:00", + "NullableDateTimeProperty": null, + "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", + "NullableDateTimeOffsetProperty": null + } + """; + + // Act + var json = JsonSerializer.Serialize(originalModel, sourceGenOptions); + var deserializedModel = JsonSerializer.Deserialize(json, sourceGenOptions); + + // Assert + Assert.NotNull(deserializedModel); + Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); + Assert.Null(deserializedModel.NullableDateTimeProperty); + Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); + Assert.Null(deserializedModel.NullableDateTimeOffsetProperty); + Assert.Equal(expectedJson, json); + } + + [Fact] + public void SourceGenerator_WithResolver_WithFormatAttribute_UsingContext() + { + // Arrange + var testModelType = typeof(SourceGeneratorWithResolverFormatModel); + var context = new DateTimeConverterResolver(ResolverModelJsonSerializerContext.Default); + var originalModel = new SourceGeneratorWithResolverFormatModel + { + DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), + NullableDateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero) + }; + const string expectedJson = """ + { + "DateTimeProperty": "2023-10-01T12:00:00", + "NullableDateTimeProperty": "2023-10-01T12:00:00", + "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", + "NullableDateTimeOffsetProperty": "2023-10-01T12:00:00.000Z" + } + """; + + // Act + var json = JsonSerializer.Serialize(originalModel, testModelType, context); + var deserializedModel = (SourceGeneratorWithResolverFormatModel?)JsonSerializer.Deserialize(json, testModelType, context); + + // Assert + Assert.NotNull(deserializedModel); + Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); + Assert.Equal(originalModel.NullableDateTimeProperty, deserializedModel.NullableDateTimeProperty); + Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); + Assert.Equal(originalModel.NullableDateTimeOffsetProperty, deserializedModel.NullableDateTimeOffsetProperty); + Assert.Equal(expectedJson, json); + } + + [Fact] + public void SourceGenerator_WithResolver_WithFormatAttribute_WithNullValues_UsingContext() + { + // Arrange + var testModelType = typeof(SourceGeneratorWithResolverFormatModel); + var context = new DateTimeConverterResolver(ResolverModelJsonSerializerContext.Default); + var originalModel = new SourceGeneratorWithResolverFormatModel + { + DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), + NullableDateTimeProperty = null, + DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), + NullableDateTimeOffsetProperty = null + }; + const string expectedJson = """ + { + "DateTimeProperty": "2023-10-01T12:00:00", + "NullableDateTimeProperty": null, + "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", + "NullableDateTimeOffsetProperty": null + } + """; + + // Act + var json = JsonSerializer.Serialize(originalModel, testModelType, context); + var deserializedModel = (SourceGeneratorWithResolverFormatModel?)JsonSerializer.Deserialize(json, testModelType, context); + + // Assert + Assert.NotNull(deserializedModel); + Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); + Assert.Equal(originalModel.NullableDateTimeProperty, deserializedModel.NullableDateTimeProperty); + Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); + Assert.Equal(originalModel.NullableDateTimeOffsetProperty, deserializedModel.NullableDateTimeOffsetProperty); + Assert.Equal(expectedJson, json); + } } \ No newline at end of file diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeFormatConverterTests.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeFormatConverterTests.cs index 22de547..f785071 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeFormatConverterTests.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/JsonDateTimeFormatConverterTests.cs @@ -6,14 +6,14 @@ namespace Scarlet.System.Text.Json.DateTimeConverter.Tests; public class JsonDateTimeFormatConverterTests { [Fact] - public void SerializeAndDeserialize_Reflection_TestModel_ShouldMatchOriginal() + public void ReflectionBased_CompleteModel_WithFormatConverter() { // Arrange var options = new JsonSerializerOptions { WriteIndented = true }; - var originalModel = new TestModelSourceGenerator + var originalModel = new SourceGeneratorWithConverterModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), @@ -31,7 +31,7 @@ public void SerializeAndDeserialize_Reflection_TestModel_ShouldMatchOriginal() // Act var json = JsonSerializer.Serialize(originalModel, options); - var deserializedModel = JsonSerializer.Deserialize(json, options); + var deserializedModel = JsonSerializer.Deserialize(json, options); // Assert Assert.NotNull(deserializedModel); @@ -43,14 +43,14 @@ public void SerializeAndDeserialize_Reflection_TestModel_ShouldMatchOriginal() } [Fact] - public void SerializeAndDeserialize_Reflection_TestModel_WithNullValues_ShouldMatchOriginal() + public void ReflectionBased_CompleteModel_WithFormatConverter_WithNullValues() { // Arrange var options = new JsonSerializerOptions { WriteIndented = true }; - var originalModel = new TestModelSourceGenerator + var originalModel = new SourceGeneratorWithConverterModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = null, @@ -68,7 +68,7 @@ public void SerializeAndDeserialize_Reflection_TestModel_WithNullValues_ShouldMa // Act var json = JsonSerializer.Serialize(originalModel, options); - var deserializedModel = JsonSerializer.Deserialize(json, options); + var deserializedModel = JsonSerializer.Deserialize(json, options); // Assert Assert.NotNull(deserializedModel); @@ -80,12 +80,12 @@ public void SerializeAndDeserialize_Reflection_TestModel_WithNullValues_ShouldMa } [Fact] - public void SerializeAndDeserialize_SourceGenerator_TestModel_ShouldMatchOriginal() + public void SourceGenerator_CompleteModel_WithFormatConverter() { // Arrange - var testModelType = typeof(TestModelSourceGenerator); - var context = TestModelSourceGeneratorJsonSerializerContext.Default; - var originalModel = new TestModelSourceGenerator + var testModelType = typeof(SourceGeneratorWithConverterModel); + var context = ConverterModelJsonSerializerContext.Default; + var originalModel = new SourceGeneratorWithConverterModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), @@ -103,7 +103,7 @@ public void SerializeAndDeserialize_SourceGenerator_TestModel_ShouldMatchOrigina // Act var json = JsonSerializer.Serialize(originalModel, testModelType, context); - var deserializedModel = (TestModelSourceGenerator?)JsonSerializer.Deserialize(json, testModelType, context); + var deserializedModel = (SourceGeneratorWithConverterModel?)JsonSerializer.Deserialize(json, testModelType, context); // Assert Assert.NotNull(deserializedModel); @@ -115,85 +115,12 @@ public void SerializeAndDeserialize_SourceGenerator_TestModel_ShouldMatchOrigina } [Fact] - public void SerializeAndDeserialize_SourceGenerator_TypeInfoResolver_TestModel_ShouldMatchOriginal() + public void SourceGenerator_CompleteModel_WithFormatConverter_WithNullValues() { // Arrange - var sourceGenOptions = new JsonSerializerOptions - { - WriteIndented = true, - TypeInfoResolver = new DateTimeConverterResolver(TestModelSourceGeneratorJsonSerializerContext.Default) - }; - var originalModel = new TestModelSourceGeneratorAttributes - { - DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), - NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), - DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), - NullableDateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero) - }; - const string expectedJson = """ - { - "DateTimeProperty": "2023-10-01T12:00:00", - "NullableDateTimeProperty": "2023-10-01T12:00:00", - "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", - "NullableDateTimeOffsetProperty": "2023-10-01T12:00:00.000Z" - } - """; - - // Act - var json = JsonSerializer.Serialize(originalModel, sourceGenOptions); - var deserializedModel = JsonSerializer.Deserialize(json, sourceGenOptions); - - // Assert - Assert.NotNull(deserializedModel); - Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); - Assert.Equal(originalModel.NullableDateTimeProperty, deserializedModel.NullableDateTimeProperty); - Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); - Assert.Equal(originalModel.NullableDateTimeOffsetProperty, deserializedModel.NullableDateTimeOffsetProperty); - Assert.Equal(expectedJson, json); - } - - [Fact] - public void SerializeAndDeserialize_SourceGeneratorWithResolver_JsonSerializerContext_TestModel_ShouldMatchOriginal() - { - // Arrange - var testModelType = typeof(TestModelSourceGeneratorAttributes); - var context = new DateTimeConverterResolver(TestModelSourceGeneratorJsonSerializerContext.Default); - var originalModel = new TestModelSourceGeneratorAttributes - { - DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), - NullableDateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), - DateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero), - NullableDateTimeOffsetProperty = new DateTimeOffset(2023, 10, 1, 12, 0, 0, TimeSpan.Zero) - }; - const string expectedJson = """ - { - "DateTimeProperty": "2023-10-01T12:00:00", - "NullableDateTimeProperty": "2023-10-01T12:00:00", - "DateTimeOffsetProperty": "2023-10-01T12:00:00.000Z", - "NullableDateTimeOffsetProperty": "2023-10-01T12:00:00.000Z" - } - """; - - // Act - var json = JsonSerializer.Serialize(originalModel, testModelType, context); - var deserializedModel = (TestModelSourceGeneratorAttributes?)JsonSerializer.Deserialize(json, testModelType, context); - - // Assert - Assert.NotNull(deserializedModel); - Assert.Equal(originalModel.DateTimeProperty, deserializedModel.DateTimeProperty); - Assert.Equal(originalModel.NullableDateTimeProperty, deserializedModel.NullableDateTimeProperty); - Assert.Equal(originalModel.DateTimeOffsetProperty, deserializedModel.DateTimeOffsetProperty); - Assert.Equal(originalModel.NullableDateTimeOffsetProperty, deserializedModel.NullableDateTimeOffsetProperty); - Assert.Equal(expectedJson, json); - } - - [Fact] - public void SerializeAndDeserialize_SourceGenerator_TestModel_WithNullValues_ShouldMatchOriginal() - { - // Arrange - var testModelType = typeof(TestModelSourceGenerator); - var context = TestModelSourceGeneratorJsonSerializerContext.Default; - var originalModel = new TestModelSourceGenerator + var testModelType = typeof(SourceGeneratorWithConverterModel); + var context = ConverterModelJsonSerializerContext.Default; + var originalModel = new SourceGeneratorWithConverterModel { DateTimeProperty = new DateTime(2023, 10, 1, 12, 0, 0, DateTimeKind.Utc), NullableDateTimeProperty = null, @@ -211,7 +138,7 @@ public void SerializeAndDeserialize_SourceGenerator_TestModel_WithNullValues_Sho // Act var json = JsonSerializer.Serialize(originalModel, testModelType, context); - var deserializedModel = (TestModelSourceGenerator?)JsonSerializer.Deserialize(json, testModelType, context); + var deserializedModel = (SourceGeneratorWithConverterModel?)JsonSerializer.Deserialize(json, testModelType, context); // Assert Assert.NotNull(deserializedModel); diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelJsonDateTimeConverter.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/ReflectionBasedModel.cs similarity index 62% rename from src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelJsonDateTimeConverter.cs rename to src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/ReflectionBasedModel.cs index 8c28ae3..c11a88d 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelJsonDateTimeConverter.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/ReflectionBasedModel.cs @@ -1,6 +1,11 @@ namespace Scarlet.System.Text.Json.DateTimeConverter.Tests.Model; -public class TestModelJsonDateTimeConverter +/// +/// Test model demonstrating JsonDateTimeConverter attribute usage with reflection-based serialization. +/// This model uses the JsonDateTimeConverter attribute which only works with reflection-based +/// System.Text.Json serialization (not with source generators). +/// +public class ReflectionBasedModel { [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss")] public DateTime DateTimeProperty { get; set; } diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGenerator.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithConverterModel.cs similarity index 76% rename from src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGenerator.cs rename to src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithConverterModel.cs index eb7ac70..c5164d7 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGenerator.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithConverterModel.cs @@ -2,7 +2,12 @@ namespace Scarlet.System.Text.Json.DateTimeConverter.Tests.Model; -public class TestModelSourceGenerator +/// +/// Test model demonstrating JsonDateTimeFormatConverter usage with source generators. +/// This model uses the JsonConverter attribute with JsonDateTimeFormatConverter which works +/// with both source generator and reflection-based serialization. +/// +public class SourceGeneratorWithConverterModel { [JsonConverter(typeof(JsonDateTimeFormatConverter))] public DateTime DateTimeProperty { get; set; } diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithResolverFormatModel.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithResolverFormatModel.cs new file mode 100644 index 0000000..7879782 --- /dev/null +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/SourceGeneratorWithResolverFormatModel.cs @@ -0,0 +1,21 @@ +namespace Scarlet.System.Text.Json.DateTimeConverter.Tests.Model; + +/// +/// Test model demonstrating JsonDateTimeFormatAttribute usage with DateTimeConverterResolver (.NET 9+). +/// This model uses the JsonDateTimeFormatAttribute (derives from Attribute only) which, when combined with DateTimeConverterResolver, +/// works with source generators in .NET 9 and above without producing SYSLIB1223 warnings. +/// +public class SourceGeneratorWithResolverFormatModel +{ + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss")] + public DateTime DateTimeProperty { get; set; } + + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss")] + public DateTime? NullableDateTimeProperty { get; set; } + + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss.fffZ")] + public DateTimeOffset DateTimeOffsetProperty { get; set; } + + [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss.fffZ")] + public DateTimeOffset? NullableDateTimeOffsetProperty { get; set; } +} diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorAttributes.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorAttributes.cs deleted file mode 100644 index daa2b8a..0000000 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorAttributes.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Scarlet.System.Text.Json.DateTimeConverter.Tests.Model; - -public class TestModelSourceGeneratorAttributes -{ - [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss")] - public DateTime DateTimeProperty { get; set; } - - [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss")] - public DateTime? NullableDateTimeProperty { get; set; } - - [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss.fffZ")] - public DateTimeOffset DateTimeOffsetProperty { get; set; } - - [JsonDateTimeConverter("yyyy-MM-ddTHH:mm:ss.fffZ")] - public DateTimeOffset? NullableDateTimeOffsetProperty { get; set; } -} \ No newline at end of file diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorJsonSerializerContext.cs b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorJsonSerializerContext.cs index 3acf9bd..1f86991 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorJsonSerializerContext.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter.Tests/Model/TestModelSourceGeneratorJsonSerializerContext.cs @@ -1,8 +1,17 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Scarlet.System.Text.Json.DateTimeConverter.Tests.Model; -[JsonSerializable(typeof(TestModelSourceGenerator))] -[JsonSerializable(typeof(TestModelSourceGeneratorAttributes))] +/// +/// JsonSerializerContext for models using JsonConverter attribute with JsonDateTimeFormatConverter. +/// +[JsonSerializable(typeof(SourceGeneratorWithConverterModel))] [JsonSourceGenerationOptions(WriteIndented = true)] -public sealed partial class TestModelSourceGeneratorJsonSerializerContext : JsonSerializerContext; \ No newline at end of file +public sealed partial class ConverterModelJsonSerializerContext : JsonSerializerContext; + +/// +/// JsonSerializerContext for models using attributes with DateTimeConverterResolver. +/// +[JsonSerializable(typeof(SourceGeneratorWithResolverFormatModel))] +[JsonSourceGenerationOptions(WriteIndented = true)] +public sealed partial class ResolverModelJsonSerializerContext : JsonSerializerContext; diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter/DateTimeConverterResolver.cs b/src/Scarlet.System.Text.Json.DateTimeConverter/DateTimeConverterResolver.cs index f47765b..faf142d 100644 --- a/src/Scarlet.System.Text.Json.DateTimeConverter/DateTimeConverterResolver.cs +++ b/src/Scarlet.System.Text.Json.DateTimeConverter/DateTimeConverterResolver.cs @@ -33,9 +33,15 @@ public DateTimeConverterResolver(JsonSerializerOptions options) : base(options) foreach (var jsonPropertyInfo in jsonTypeInfo.Properties) { - if (jsonPropertyInfo.AttributeProvider?.GetCustomAttributes(typeof(JsonDateTimeConverterAttribute), inherit: false) is [JsonDateTimeConverterAttribute attr, ..]) + // First check for JsonDateTimeFormatAttribute (preferred for source generators, no warnings) + if (jsonPropertyInfo.AttributeProvider?.GetCustomAttributes(typeof(JsonDateTimeFormatAttribute), inherit: false) is [JsonDateTimeFormatAttribute formatAttr, ..]) { - jsonPropertyInfo.CustomConverter = attr.CreateConverter(jsonPropertyInfo.PropertyType); + jsonPropertyInfo.CustomConverter = DateTimeConverterFactoryHelper.CreateConverter(jsonPropertyInfo.PropertyType, formatAttr.Format); + } + // Fall back to JsonDateTimeConverterAttribute for backward compatibility + else if (jsonPropertyInfo.AttributeProvider?.GetCustomAttributes(typeof(JsonDateTimeConverterAttribute), inherit: false) is [JsonDateTimeConverterAttribute converterAttr, ..]) + { + jsonPropertyInfo.CustomConverter = converterAttr.CreateConverter(jsonPropertyInfo.PropertyType); } } diff --git a/src/Scarlet.System.Text.Json.DateTimeConverter/JsonDateTimeFormatAttribute.cs b/src/Scarlet.System.Text.Json.DateTimeConverter/JsonDateTimeFormatAttribute.cs new file mode 100644 index 0000000..7344bcd --- /dev/null +++ b/src/Scarlet.System.Text.Json.DateTimeConverter/JsonDateTimeFormatAttribute.cs @@ -0,0 +1,48 @@ +namespace Scarlet.System.Text.Json.DateTimeConverter; + +/// +/// Specifies a custom date format for , (including Nullables) when used with source generators and . +/// +/// +/// This attribute is specifically designed for use with .NET 9+ source generators and . +/// Unlike , this attribute does not derive from JsonConverterAttribute and will not produce SYSLIB1223 warnings. +/// For reflection-based serialization, use instead. +/// +/// +/// Example usage with source generator: +/// +/// public class Model +/// { +/// [JsonDateTimeFormat("yyyy-MM-ddTHH:mm:ss.fffZ")] +/// public DateTimeOffset DateTimeOffsetProperty { get; set; } +/// } +/// +/// [JsonSerializable(typeof(Model))] +/// public partial class MyJsonContext : JsonSerializerContext { } +/// +/// // Usage: +/// var options = new JsonSerializerOptions +/// { +/// TypeInfoResolver = new DateTimeConverterResolver(MyJsonContext.Default) +/// }; +/// var json = JsonSerializer.Serialize(model, options); +/// +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +public sealed class JsonDateTimeFormatAttribute : Attribute +{ + /// + /// Gets the date format string. + /// + public string Format { get; } + + /// + /// Initializes a new instance of the class with the specified date format. + /// + /// The date format string. + public JsonDateTimeFormatAttribute(string format) + { + ArgumentNullException.ThrowIfNull(format); + Format = format; + } +}