From 6d7c94ce6beca39775aae311c9b0d6ffa166607f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:03:04 +0100 Subject: [PATCH 1/6] chore(release): drop the prerelease tag for 2.0.0 package.json now reports the first stable version, 2.0.0, and the release-flow docs describe the stable line instead of the 2.0.0-prerelease.* line. --- docs/wiki/Release-Flow.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index ad9e4e7..b9bcf6b 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -4,7 +4,7 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g ## Versioning -The package version comes from `package.json` (`version` field). The repo is currently on the `2.0.0-prerelease.*` line. Bump `package.json` to release a new version. +The package version comes from `package.json` (`version` field). The current stable line is `2.0.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. diff --git a/package.json b/package.json index 29fd92c..3f9b711 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.28", + "version": "2.0.0", "private": true, "license": "MIT", "author": { From f767a165e4c10cb24eae333dd4a635bbfd44c0c3 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:03:10 +0100 Subject: [PATCH 2/6] chore(analyzers): ship the ZODSGEN020-036 and ZODSASP rules in 2.0.0 Fold every rule that was pending in AnalyzerReleases.Unshipped.md into the new 2.0.0 section of AnalyzerReleases.Shipped.md, preserving each rule id, category, severity and notes. The unshipped file keeps the standard Roslyn header and an empty new-rules table. --- .../AnalyzerReleases.Shipped.md | 45 +++++++++++++------ .../AnalyzerReleases.Unshipped.md | 22 ++------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index dae3318..0c5d06d 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -6,7 +6,7 @@ |---|---|---|---| | ZODSGEN001 | ZodSharp.SourceGenerator | Error | This is effectively a fatal incident where the source generator has failed. | -## Release 2.0 +## Release 2.0.0 ### New Rules @@ -16,16 +16,33 @@ | ZODSGEN004 | ZodSharp.SourceGenerator | Error | Unsupported LengthAttribute target | ZODSGEN005 | ZodSharp.SourceGenerator | Error | Invalid DataAnnotations error message resource configuration | ZODSGEN006 | ZodSharp.SourceGenerator | Error | Unsupported DataAnnotations usage -| ZODSGEN007 | ZodSharp.SourceGenerator | Error | Custom validation method not found | -| ZODSGEN008 | ZodSharp.SourceGenerator | Error | Invalid return type for custom validation method | -| ZODSGEN009 | ZodSharp.SourceGenerator | Error | Invalid custom validation method parameter count | -| ZODSGEN010 | ZodSharp.SourceGenerator | Error | Invalid custom validation model parameter | -| ZODSGEN011 | ZodSharp.SourceGenerator | Error | Missing or invalid CancellationToken parameter | -| ZODSGEN012 | ZodSharp.SourceGenerator | Error | Generic custom validation method unsupported | -| ZODSGEN013 | ZodSharp.SourceGenerator | Error | Custom validation method must be static when defined on model type | -| ZODSGEN014 | ZodSharp.SourceGenerator | Error | Inaccessible custom validation method | -| ZODSGEN015 | ZodSharp.SourceGenerator | Error | Ambiguous custom validation method overloads | -| ZODSGEN016 | ZodSharp.SourceGenerator | Error | Invalid custom validation method name | -| ZODSGEN017 | ZodSharp.SourceGenerator | Error | Abstract custom validation method unsupported | -| ZODSGEN018 | ZodSharp.SourceGenerator | Error | Partial custom validation method has not been implemented | -| ZODSGEN019 | ZodSharp.SourceGenerator | Error | Invalid custom validation method parameter modifier | +| ZODSGEN007 | ZodSharp.SourceGenerator | Error | Custom validation method not found +| ZODSGEN008 | ZodSharp.SourceGenerator | Error | Invalid return type for custom validation method +| ZODSGEN009 | ZodSharp.SourceGenerator | Error | Invalid custom validation method parameter count +| ZODSGEN010 | ZodSharp.SourceGenerator | Error | Invalid custom validation model parameter +| ZODSGEN011 | ZodSharp.SourceGenerator | Error | Missing or invalid CancellationToken parameter +| ZODSGEN012 | ZodSharp.SourceGenerator | Error | Generic custom validation method unsupported +| ZODSGEN013 | ZodSharp.SourceGenerator | Error | Custom validation method must be static when defined on model type +| ZODSGEN014 | ZodSharp.SourceGenerator | Error | Inaccessible custom validation method +| ZODSGEN015 | ZodSharp.SourceGenerator | Error | Ambiguous custom validation method overloads +| ZODSGEN016 | ZodSharp.SourceGenerator | Error | Invalid custom validation method name +| ZODSGEN017 | ZodSharp.SourceGenerator | Error | Abstract custom validation method unsupported +| ZODSGEN018 | ZodSharp.SourceGenerator | Error | Partial custom validation method has not been implemented +| ZODSGEN019 | ZodSharp.SourceGenerator | Error | Invalid custom validation method parameter modifier +| ZODSGEN020 | ZodSharp.SourceGenerator | Error | CompareAttribute references an unknown property +| ZODSGEN021 | ZodSharp.SourceGenerator | Error | Add a reference to System.ComponentModel.DataAnnotations +| ZODSGEN027 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference to Microsoft.Extensions.Options +| ZODSGEN028 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference type +| ZODSGEN029 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook and async custom validation methods are mutually exclusive +| ZODSGEN030 | ZodSharp.SourceGenerator | Error | Custom rule does not implement IValidationRule for the property type (or an unbound generic rule cannot be closed with it) +| ZODSGEN031 | ZodSharp.SourceGenerator | Error | Unable to map an attribute value to a custom rule constructor parameter +| ZODSGEN032 | ZodSharp.SourceGenerator | Error | Unable to generate a validation attribute for a custom rule +| ZODSGEN033 | ZodSharp.SourceGenerator | Warning | Rule attribute is applied to a type that gets no generated schema +| ZODSGEN034 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook requires the target type (and its containing types) to be declared partial +| ZODSGEN035 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook must be declared as 'partial void OnZodValidate(RefineCtx context)' +| ZODSGEN036 | ZodSharp.SourceGenerator | Error | The synchronous refinement method contract has been replaced by the OnZodValidate hook +| ZODSASP001 | ZodSharp.SourceGenerator | Warning | MessageFormat placeholder is not declared in Parameters +| ZODSASP002 | ZodSharp.SourceGenerator | Warning | Error type containing type must be partial +| ZODSASP003 | ZodSharp.SourceGenerator | Warning | ErrorType field must be static readonly +| ZODSASP100 | ZodSharp.SourceGenerator | Error | Unhandled exception in the ErrorType source generator +| ZODSASP101 | ZodSharp.SourceGenerator | Error | ErrorType Parameters could not be extracted diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 57a70fd..7b845f4 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,21 +1,7 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + ### New Rules Rule ID | Category | Severity | Notes ---------|----------|----------|------ -ZODSGEN020 | ZodSharp.SourceGenerator | Error | CompareAttribute references an unknown property -ZODSGEN021 | ZodSharp.SourceGenerator | Error | Add a reference to System.ComponentModel.DataAnnotations -ZODSGEN027 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference to Microsoft.Extensions.Options -ZODSGEN028 | ZodSharp.SourceGenerator | Error | IValidateOptions generation requires a reference type -ZODSGEN029 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook and async custom validation methods are mutually exclusive -ZODSGEN030 | ZodSharp.SourceGenerator | Error | Custom rule does not implement IValidationRule for the property type (or an unbound generic rule cannot be closed with it) -ZODSGEN031 | ZodSharp.SourceGenerator | Error | Unable to map an attribute value to a custom rule constructor parameter -ZODSGEN032 | ZodSharp.SourceGenerator | Error | Unable to generate a validation attribute for a custom rule -ZODSGEN033 | ZodSharp.SourceGenerator | Warning | Rule attribute is applied to a type that gets no generated schema -ZODSGEN034 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook requires the target type (and its containing types) to be declared partial -ZODSGEN035 | ZodSharp.SourceGenerator | Error | OnZodValidate refinement hook must be declared as 'partial void OnZodValidate(RefineCtx context)' -ZODSGEN036 | ZodSharp.SourceGenerator | Error | The synchronous refinement method contract has been replaced by the OnZodValidate hook -ZODSASP001 | ZodSharp.SourceGenerator | Warning | MessageFormat placeholder is not declared in Parameters -ZODSASP002 | ZodSharp.SourceGenerator | Warning | Error type containing type must be partial -ZODSASP003 | ZodSharp.SourceGenerator | Warning | ErrorType field must be static readonly -ZODSASP100 | ZodSharp.SourceGenerator | Error | Unhandled exception in the ErrorType source generator -ZODSASP101 | ZodSharp.SourceGenerator | Error | ErrorType Parameters could not be extracted +--------|----------|----------|------- From 86262af56d061cdee8657e9d0c0f49b241ece094 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:03:48 +0100 Subject: [PATCH 3/6] refactor(json): scope JSON Schema import types per package Move FromJsonSchemaParser, JsonSchemaSerializerOptions and ZExtensions into the ZodSharp.JsonSchema.SystemTextJson and ZodSharp.JsonSchema.NewtonsoftJson namespaces so both JSON packages can be referenced from one project without extern alias. Remove the empty FromJsonSchemaOptions type and the options parameter from Z.FromJsonSchema and FromJsonSchemaParser.Parse in both packages. Bind the JSON Schema keyword names ($schema, $id, $ref, $defs) when reading and writing JsonSchemaDefinition, and ignore JSON metadata handling in the Newtonsoft reader, which previously consumed $ref as metadata and produced null property schemas. BREAKING CHANGE: Z.FromJsonSchema no longer accepts FromJsonSchemaOptions and now requires the JSON integration package namespace import. --- src/src/Examples.CLI/JsonSchemaExamples.cs | 1 + .../JsonSchema/JsonSchemaSerializerOptions.cs | 29 -------- .../FromJsonSchemaParser.cs | 26 ++----- .../JsonSchemaSerializerOptions.cs | 71 +++++++++++++++++++ .../NewtonsoftJson}/ZExtensions.cs | 15 ++-- src/src/NewtonsoftJson/Sdk/README.md | 3 + .../FromJsonSchemaParser.cs | 26 ++----- .../JsonSchemaSerializerOptions.cs | 2 +- .../SystemTextJson}/ZExtensions.cs | 15 ++-- src/src/SystemTextJson/Sdk/README.md | 3 + .../JsonSchema/JsonSchemaDefinition.cs | 6 ++ 11 files changed, 109 insertions(+), 88 deletions(-) delete mode 100644 src/src/NewtonsoftJson/JsonSchema/JsonSchemaSerializerOptions.cs rename src/src/NewtonsoftJson/JsonSchema/{ => NewtonsoftJson}/FromJsonSchemaParser.cs (96%) create mode 100644 src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs rename src/src/NewtonsoftJson/{ => JsonSchema/NewtonsoftJson}/ZExtensions.cs (61%) rename src/src/SystemTextJson/JsonSchema/{ => SystemTextJson}/FromJsonSchemaParser.cs (96%) rename src/src/SystemTextJson/JsonSchema/{ => SystemTextJson}/JsonSchemaSerializerOptions.cs (95%) rename src/src/SystemTextJson/{ => JsonSchema/SystemTextJson}/ZExtensions.cs (61%) diff --git a/src/src/Examples.CLI/JsonSchemaExamples.cs b/src/src/Examples.CLI/JsonSchemaExamples.cs index 71e65ee..3a56d8e 100644 --- a/src/src/Examples.CLI/JsonSchemaExamples.cs +++ b/src/src/Examples.CLI/JsonSchemaExamples.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.NewtonsoftJson; namespace ZodSharp.Examples.CLI; diff --git a/src/src/NewtonsoftJson/JsonSchema/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/JsonSchemaSerializerOptions.cs deleted file mode 100644 index 0836906..0000000 --- a/src/src/NewtonsoftJson/JsonSchema/JsonSchemaSerializerOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Newtonsoft.Json; - -namespace ZodSharp.JsonSchema; - -/// -/// Options for JSON serialization/deserialization of JSON Schema. -/// -public static class JsonSchemaSerializerOptions -{ - /// - /// Default settings for JSON Schema serialization. - /// Uses camelCase property naming and ignores null values. - /// - public static readonly JsonSerializerSettings Default = new() - { - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented, - ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), - }; - - /// - /// Settings for reading JSON Schema with flexible property matching. - /// - public static readonly JsonSerializerSettings Reading = new() - { - NullValueHandling = NullValueHandling.Ignore, - MissingMemberHandling = MissingMemberHandling.Ignore, - }; -} diff --git a/src/src/NewtonsoftJson/JsonSchema/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs similarity index 96% rename from src/src/NewtonsoftJson/JsonSchema/FromJsonSchemaParser.cs rename to src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index ec6a3c2..eb684a4 100644 --- a/src/src/NewtonsoftJson/JsonSchema/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -5,15 +5,7 @@ using ZodSharp.Core; using ZodSharp.Schemas; -namespace ZodSharp.JsonSchema; - -/// -/// Options for parsing JSON Schema to ZodSharp schemas. -/// -public class FromJsonSchemaOptions -{ - // Reserved for future options -} +namespace ZodSharp.JsonSchema.NewtonsoftJson; /// /// Parses JSON Schema (Draft 2020-12) to ZodSharp schemas. @@ -25,13 +17,8 @@ public static class FromJsonSchemaParser /// Parses a JSON Schema definition to a ZodSharp schema. /// /// The JSON Schema definition - /// Parsing options /// A ZodSharp schema that validates according to the JSON Schema - public static IZodSchema Parse(JsonSchemaDefinition schema, -#pragma warning disable IDE0060 // Remove unused parameter - FromJsonSchemaOptions? options = null -#pragma warning restore IDE0060 // Remove unused parameter - ) + public static IZodSchema Parse(JsonSchemaDefinition schema) { if (schema is null) { @@ -47,9 +34,8 @@ public static IZodSchema Parse(JsonSchemaDefinition schema, /// Parses a JSON Schema string to a ZodSharp schema. /// /// The JSON Schema as a string - /// Parsing options /// A ZodSharp schema that validates according to the JSON Schema - public static IZodSchema Parse(string jsonSchema, FromJsonSchemaOptions? options = null) + public static IZodSchema Parse(string jsonSchema) { var schema = JsonConvert.DeserializeObject( jsonSchema, @@ -58,7 +44,7 @@ public static IZodSchema Parse(string jsonSchema, FromJsonSchema return schema == null ? throw new ArgumentException("Invalid JSON Schema: could not parse JSON", nameof(jsonSchema)) - : Parse(schema, options); + : Parse(schema); } sealed class ConversionContext(JsonSchemaDefinition rootSchema, Dictionary defs) @@ -258,7 +244,9 @@ static IZodSchema ResolveRef(string refPath, ConversionContext c { if (!refPath.StartsWith('#')) { - throw new NotSupportedException("External $ref is not supported, only local refs (#/...) are allowed"); + throw new NotSupportedException( + $"External $ref '{refPath}' is not supported. Only local references ('#/...', for example '#/$defs/Name') can be resolved; inline or pre-resolve external schemas before importing." + ); } // Check if already resolved diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs new file mode 100644 index 0000000..85afe21 --- /dev/null +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs @@ -0,0 +1,71 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace ZodSharp.JsonSchema.NewtonsoftJson; + +/// +/// Resolves the JSON Schema keyword property names ($schema, $id, $ref and +/// $defs) while keeping camelCase naming for every other member. +/// +sealed class JsonSchemaKeywordContractResolver : Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver +{ + static readonly Dictionary KeywordNames = new(StringComparer.Ordinal) + { + ["Schema"] = "$schema", + ["Id"] = "$id", + ["Ref"] = "$ref", + ["Defs"] = "$defs", + }; + + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var properties = base.CreateProperties(type, memberSerialization); + + if (type != typeof(ZodSharp.JsonSchema.JsonSchemaDefinition)) + { + return properties; + } + + foreach (var property in properties) + { + if ( + property.UnderlyingName != null + && KeywordNames.TryGetValue(property.UnderlyingName, out var keywordName) + ) + { + property.PropertyName = keywordName; + } + } + + return properties; + } +} + +/// +/// Options for JSON serialization/deserialization of JSON Schema. +/// +public static class JsonSchemaSerializerOptions +{ + /// + /// Default settings for JSON Schema serialization. + /// Uses camelCase property naming and ignores null values. + /// + public static readonly JsonSerializerSettings Default = new() + { + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented, + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + ContractResolver = new JsonSchemaKeywordContractResolver(), + }; + + /// + /// Settings for reading JSON Schema with flexible property matching. + /// + public static readonly JsonSerializerSettings Reading = new() + { + NullValueHandling = NullValueHandling.Ignore, + MissingMemberHandling = MissingMemberHandling.Ignore, + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + ContractResolver = new JsonSchemaKeywordContractResolver(), + }; +} diff --git a/src/src/NewtonsoftJson/ZExtensions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs similarity index 61% rename from src/src/NewtonsoftJson/ZExtensions.cs rename to src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs index 8c16935..d609cd8 100644 --- a/src/src/NewtonsoftJson/ZExtensions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs @@ -1,7 +1,6 @@ using ZodSharp.Core; -using ZodSharp.JsonSchema; -namespace ZodSharp; +namespace ZodSharp.JsonSchema.NewtonsoftJson; [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] #if !NETSTANDARD2_1_OR_GREATER @@ -15,18 +14,14 @@ public static class ZExtensions /// Creates a ZodSharp schema from a JSON Schema definition. /// Enables consuming schemas defined in TypeScript Zod. /// - public static IZodSchema FromJsonSchema( - JsonSchemaDefinition schema, - FromJsonSchemaOptions? options = null - ) => FromJsonSchemaParser.Parse(schema, options); + public static IZodSchema FromJsonSchema(JsonSchemaDefinition schema) => + FromJsonSchemaParser.Parse(schema); /// /// Creates a ZodSharp schema from a JSON Schema string. /// Enables consuming schemas defined in TypeScript Zod via JSON files or APIs. /// - public static IZodSchema FromJsonSchema( - string jsonSchema, - FromJsonSchemaOptions? options = null - ) => FromJsonSchemaParser.Parse(jsonSchema, options); + public static IZodSchema FromJsonSchema(string jsonSchema) => + FromJsonSchemaParser.Parse(jsonSchema); } } diff --git a/src/src/NewtonsoftJson/Sdk/README.md b/src/src/NewtonsoftJson/Sdk/README.md index 4737cec..71d6909 100644 --- a/src/src/NewtonsoftJson/Sdk/README.md +++ b/src/src/NewtonsoftJson/Sdk/README.md @@ -48,6 +48,9 @@ var value = JsonConvert.DeserializeObject(json, converter); ## Import from JSON Schema ```csharp +using ZodSharp; +using ZodSharp.JsonSchema.NewtonsoftJson; + var schema = Z.FromJsonSchema(jsonSchemaString); var result = schema.Validate(data); ``` diff --git a/src/src/SystemTextJson/JsonSchema/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs similarity index 96% rename from src/src/SystemTextJson/JsonSchema/FromJsonSchemaParser.cs rename to src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 348f22a..7585f5f 100644 --- a/src/src/SystemTextJson/JsonSchema/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -4,15 +4,7 @@ using ZodSharp.Core; using ZodSharp.Schemas; -namespace ZodSharp.JsonSchema; - -/// -/// Options for parsing JSON Schema to ZodSharp schemas. -/// -public class FromJsonSchemaOptions -{ - // Reserved for future options -} +namespace ZodSharp.JsonSchema.SystemTextJson; /// /// Parses JSON Schema (Draft 2020-12) to ZodSharp schemas using System.Text.Json. @@ -24,13 +16,8 @@ public static class FromJsonSchemaParser /// Parses a JSON Schema definition to a ZodSharp schema. /// /// The JSON Schema definition - /// Parsing options /// A ZodSharp schema that validates according to the JSON Schema - public static IZodSchema Parse(JsonSchemaDefinition schema, -#pragma warning disable IDE0060 // Remove unused parameter - FromJsonSchemaOptions? options = null -#pragma warning restore IDE0060 // Remove unused parameter - ) + public static IZodSchema Parse(JsonSchemaDefinition schema) { if (schema is null) { @@ -46,15 +33,14 @@ public static IZodSchema Parse(JsonSchemaDefinition schema, /// Parses a JSON Schema string to a ZodSharp schema. /// /// The JSON Schema as a string - /// Parsing options /// A ZodSharp schema that validates according to the JSON Schema - public static IZodSchema Parse(string jsonSchema, FromJsonSchemaOptions? options = null) + public static IZodSchema Parse(string jsonSchema) { var schema = JsonSerializer.Deserialize(jsonSchema, JsonSchemaSerializerOptions.Reading); return schema == null ? throw new ArgumentException("Invalid JSON Schema: could not parse JSON", nameof(jsonSchema)) - : Parse(schema, options); + : Parse(schema); } sealed class ConversionContext(JsonSchemaDefinition rootSchema, Dictionary defs) @@ -254,7 +240,9 @@ static IZodSchema ResolveRef(string refPath, ConversionContext c { if (!refPath.StartsWith('#')) { - throw new NotSupportedException("External $ref is not supported, only local refs (#/...) are allowed"); + throw new NotSupportedException( + $"External $ref '{refPath}' is not supported. Only local references ('#/...', for example '#/$defs/Name') can be resolved; inline or pre-resolve external schemas before importing." + ); } // Check if already resolved diff --git a/src/src/SystemTextJson/JsonSchema/JsonSchemaSerializerOptions.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs similarity index 95% rename from src/src/SystemTextJson/JsonSchema/JsonSchemaSerializerOptions.cs rename to src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs index 9edc4b9..3933fe4 100644 --- a/src/src/SystemTextJson/JsonSchema/JsonSchemaSerializerOptions.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace ZodSharp.JsonSchema; +namespace ZodSharp.JsonSchema.SystemTextJson; /// /// Options for JSON serialization/deserialization of JSON Schema using System.Text.Json. diff --git a/src/src/SystemTextJson/ZExtensions.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/ZExtensions.cs similarity index 61% rename from src/src/SystemTextJson/ZExtensions.cs rename to src/src/SystemTextJson/JsonSchema/SystemTextJson/ZExtensions.cs index 8c16935..8185c24 100644 --- a/src/src/SystemTextJson/ZExtensions.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/ZExtensions.cs @@ -1,7 +1,6 @@ using ZodSharp.Core; -using ZodSharp.JsonSchema; -namespace ZodSharp; +namespace ZodSharp.JsonSchema.SystemTextJson; [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] #if !NETSTANDARD2_1_OR_GREATER @@ -15,18 +14,14 @@ public static class ZExtensions /// Creates a ZodSharp schema from a JSON Schema definition. /// Enables consuming schemas defined in TypeScript Zod. /// - public static IZodSchema FromJsonSchema( - JsonSchemaDefinition schema, - FromJsonSchemaOptions? options = null - ) => FromJsonSchemaParser.Parse(schema, options); + public static IZodSchema FromJsonSchema(JsonSchemaDefinition schema) => + FromJsonSchemaParser.Parse(schema); /// /// Creates a ZodSharp schema from a JSON Schema string. /// Enables consuming schemas defined in TypeScript Zod via JSON files or APIs. /// - public static IZodSchema FromJsonSchema( - string jsonSchema, - FromJsonSchemaOptions? options = null - ) => FromJsonSchemaParser.Parse(jsonSchema, options); + public static IZodSchema FromJsonSchema(string jsonSchema) => + FromJsonSchemaParser.Parse(jsonSchema); } } diff --git a/src/src/SystemTextJson/Sdk/README.md b/src/src/SystemTextJson/Sdk/README.md index 7af1726..37528d1 100644 --- a/src/src/SystemTextJson/Sdk/README.md +++ b/src/src/SystemTextJson/Sdk/README.md @@ -46,6 +46,9 @@ var value = JsonSerializer.Deserialize(json, options); ## Import from JSON Schema ```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; + var schema = Z.FromJsonSchema(jsonSchemaString); var result = schema.Validate(data); ``` diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index bdfa81c..1be09e9 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace ZodSharp.JsonSchema; /// @@ -9,12 +11,15 @@ namespace ZodSharp.JsonSchema; public class JsonSchemaDefinition { /// $schema - The JSON Schema version URI + [JsonPropertyName("$schema")] public string? Schema { get; set; } /// $id - Schema identifier + [JsonPropertyName("$id")] public string? Id { get; set; } /// $ref - Reference to another schema + [JsonPropertyName("$ref")] public string? Ref { get; set; } /// type - The data type (string, number, integer, boolean, object, array, null) @@ -107,6 +112,7 @@ public class JsonSchemaDefinition // ========== Definitions ========== /// $defs - Schema definitions (Draft 2020-12) + [JsonPropertyName("$defs")] public Dictionary? Defs { get; set; } /// definitions - Schema definitions (Draft 07 and earlier) From c02aef4fb27ac6112a241b4bafa8ba4c4260de77 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:03:50 +0100 Subject: [PATCH 4/6] test(json): cover import, package coexistence and multi-targeting Add a JsonInterop.UnitTests project that references both JSON integration packages to prove they can be used together without extern alias, parser tests for local and external $ref plus JSON Schema keyword naming, a release-tracking guard test, and a multi-targeting test. --- src/ZodSharp.slnx | 1 + .../Json/NewtonsoftJsonImportTests.cs | 68 +++++++++ .../Json/SystemTextJsonImportTests.cs | 70 +++++++++ .../JsonInterop.UnitTests.csproj | 16 +++ .../Json/Schema/FromJsonSchemaParserTests.cs | 120 ++++++++++++++++ .../AnalyzerReleaseTrackingTests.cs | 133 ++++++++++++++++++ .../Json/Schema/FromJsonSchemaParserTests.cs | 116 +++++++++++++++ .../Core/MultiTargetingTests.cs | 33 +++++ 8 files changed, 557 insertions(+) create mode 100644 src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs create mode 100644 src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs create mode 100644 src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj create mode 100644 src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 92074a0..f6e8183 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -14,6 +14,7 @@ + diff --git a/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs new file mode 100644 index 0000000..b2f5326 --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/Json/NewtonsoftJsonImportTests.cs @@ -0,0 +1,68 @@ +using ZodSharp.JsonSchema.NewtonsoftJson; + +namespace ZodSharp.Json; + +/// +/// Newtonsoft.Json counterpart of : the Newtonsoft package's JSON +/// Schema import types are available from the same project as the System.Text.Json package's types. +/// +public class NewtonsoftJsonImportTests +{ + const string ObjectSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "properties": { "name": { "type": "string", "minLength": 1 } }, + "required": ["name"] + } + """; + + [Test] + public async Task FromJsonSchema_GivenNewtonsoftJsonPackage_ValidatesData() + { + // Arrange + var schema = Z.FromJsonSchema(ObjectSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["name"] = "Ada" }); + var invalidResult = schema.Validate(new Dictionary { ["name"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + } + + [Test] + public async Task ImportTypes_GivenBothJsonPackagesReferenced_ExposeDistinctFullNames() + { + // Arrange + var newtonsoftParser = typeof(FromJsonSchemaParser); + var systemTextJsonParser = typeof(ZodSharp.JsonSchema.SystemTextJson.FromJsonSchemaParser); + + // Act + var newtonsoftFullName = newtonsoftParser.FullName; + var systemTextJsonFullName = systemTextJsonParser.FullName; + + // Assert + await Assert.That(newtonsoftFullName).IsEqualTo("ZodSharp.JsonSchema.NewtonsoftJson.FromJsonSchemaParser"); + await Assert.That(systemTextJsonFullName).IsEqualTo("ZodSharp.JsonSchema.SystemTextJson.FromJsonSchemaParser"); + await Assert.That(newtonsoftParser).IsNotEqualTo(systemTextJsonParser); + } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenBothJsonPackagesReferenced_BindToOwnSerializerType() + { + // Arrange + var newtonsoftOptions = JsonSchemaSerializerOptions.Default; + var systemTextJsonOptions = ZodSharp.JsonSchema.SystemTextJson.JsonSchemaSerializerOptions.Default; + + // Act + var newtonsoftOptionsType = newtonsoftOptions.GetType(); + var systemTextJsonOptionsType = systemTextJsonOptions.GetType(); + + // Assert + await Assert.That(newtonsoftOptionsType).IsEqualTo(typeof(Newtonsoft.Json.JsonSerializerSettings)); + await Assert.That(systemTextJsonOptionsType).IsEqualTo(typeof(System.Text.Json.JsonSerializerOptions)); + } +} diff --git a/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs new file mode 100644 index 0000000..e8bd2df --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/Json/SystemTextJsonImportTests.cs @@ -0,0 +1,70 @@ +using ZodSharp.JsonSchema.SystemTextJson; + +namespace ZodSharp.Json; + +/// +/// Proves that Purview.ZodSharp.SystemTextJson and Purview.ZodSharp.NewtonsoftJson can be +/// referenced from the same project without extern alias: each package declares its JSON Schema +/// import types in a package-specific namespace, so the previously identical full names are now distinct. +/// Import exactly one JSON integration namespace per file. +/// +public class SystemTextJsonImportTests +{ + const string ObjectSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "properties": { "name": { "type": "string", "minLength": 1 } }, + "required": ["name"] + } + """; + + [Test] + public async Task FromJsonSchema_GivenSystemTextJsonPackage_ValidatesData() + { + // Arrange + var schema = Z.FromJsonSchema(ObjectSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["name"] = "Ada" }); + var invalidResult = schema.Validate(new Dictionary { ["name"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + } + + [Test] + public async Task ImportTypes_GivenBothJsonPackagesReferenced_ExposeDistinctFullNames() + { + // Arrange + var systemTextJsonParser = typeof(FromJsonSchemaParser); + var newtonsoftParser = typeof(ZodSharp.JsonSchema.NewtonsoftJson.FromJsonSchemaParser); + + // Act + var systemTextJsonFullName = systemTextJsonParser.FullName; + var newtonsoftFullName = newtonsoftParser.FullName; + + // Assert + await Assert.That(systemTextJsonFullName).IsEqualTo("ZodSharp.JsonSchema.SystemTextJson.FromJsonSchemaParser"); + await Assert.That(newtonsoftFullName).IsEqualTo("ZodSharp.JsonSchema.NewtonsoftJson.FromJsonSchemaParser"); + await Assert.That(systemTextJsonParser).IsNotEqualTo(newtonsoftParser); + } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenBothJsonPackagesReferenced_BindToOwnSerializerType() + { + // Arrange + var systemTextJsonOptions = JsonSchemaSerializerOptions.Default; + var newtonsoftOptions = ZodSharp.JsonSchema.NewtonsoftJson.JsonSchemaSerializerOptions.Default; + + // Act + var systemTextJsonOptionsType = systemTextJsonOptions.GetType(); + var newtonsoftOptionsType = newtonsoftOptions.GetType(); + + // Assert + await Assert.That(systemTextJsonOptionsType).IsEqualTo(typeof(System.Text.Json.JsonSerializerOptions)); + await Assert.That(newtonsoftOptionsType).IsEqualTo(typeof(Newtonsoft.Json.JsonSerializerSettings)); + } +} diff --git a/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj b/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj new file mode 100644 index 0000000..b46dbe0 --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/JsonInterop.UnitTests.csproj @@ -0,0 +1,16 @@ + + + ZodSharp + Purview.ZodSharp.JsonInterop.UnitTests + $(NoWarn);CA1849;CA1002;CA2227; + + + + + + + + + diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs new file mode 100644 index 0000000..055b045 --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -0,0 +1,120 @@ +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.NewtonsoftJson; + +namespace ZodSharp.Json.Schema; + +/// +/// Newtonsoft.Json counterpart of the System.Text.Json JSON Schema import tests: the +/// Z.FromJsonSchema extension member, local and external $ref handling, and the exported +/// serializer options for this package. +/// +public class FromJsonSchemaParserTests +{ + const string LocalRefSchema = + /*lang=json,strict*/ + """ + { + "$defs": { "name": { "type": "string", "minLength": 1 } }, + "type": "object", + "properties": { "name": { "$ref": "#/$defs/name" } }, + "required": ["name"] + } + """; + + const string ExternalRefSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "properties": { "name": { "$ref": "external.json#/$defs/name" } } + } + """; + + [Test] + public async Task FromJsonSchema_GivenLocalDefsRef_ResolvesReference() + { + // Arrange + var schema = Z.FromJsonSchema(LocalRefSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["name"] = "Ada" }); + var invalidResult = schema.Validate(new Dictionary { ["name"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + } + + [Test] + public async Task FromJsonSchema_GivenExternalRef_ThrowsNotSupportedExceptionNamingReference() + { + // Arrange + + // Act + var exception = Assert.Throws(() => Z.FromJsonSchema(ExternalRefSchema)); + + // Assert + await Assert.That(exception!.Message).Contains("External $ref 'external.json#/$defs/name' is not supported"); + await Assert.That(exception.Message).Contains("pre-resolve external schemas before importing"); + } + + [Test] + public async Task Parse_GivenDefinitionWithStringConstraint_ValidatesAgainstConstraint() + { + // Arrange + JsonSchemaDefinition definition = new() { Type = "string", MinLength = 2 }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("ab").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("a").IsSuccess).IsFalse(); + } + + [Test] + public async Task Parse_GivenNullDefinition_ThrowsArgumentNullException() + { + // Arrange + JsonSchemaDefinition definition = null!; + + // Act + var exception = Assert.Throws(() => FromJsonSchemaParser.Parse(definition)); + + // Assert + await Assert.That(exception!.ParamName).IsEqualTo("schema"); + } + + [Test] + public async Task JsonSchemaSerializerOptions_Default_UsesCamelCasePropertyNames() + { + // Arrange + + // Act + var options = JsonSchemaSerializerOptions.Default; + var usesCamelCaseResolver = + options.ContractResolver is Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver; + + // Assert + await Assert.That(usesCamelCaseResolver).IsTrue(); + await Assert.That(options.Formatting).IsEqualTo(Newtonsoft.Json.Formatting.Indented); + } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeywordNames() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Ref = "#/$defs/name", + Defs = new Dictionary { ["name"] = new() { Type = "string" } }, + }; + + // Act + var json = Newtonsoft.Json.JsonConvert.SerializeObject(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"$ref\""); + await Assert.That(json).Contains("\"$defs\""); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs new file mode 100644 index 0000000..a8a3d5d --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; + +namespace ZodSharp.SourceGenerators; + +/// +/// Guards the analyzer release tracking files: every diagnostic that ships in the stable 2.0.0 release is +/// recorded in AnalyzerReleases.Shipped.md, the unshipped file declares no new rules, and each rule +/// identifier is tracked exactly once. These files drive the Roslyn release-tracking analyzers +/// (RS2000/RS2001), which report at build time when a diagnostic is missing from either file. +/// +public partial class AnalyzerReleaseTrackingTests +{ + static readonly string ReleaseDirectory = Path.Combine( + "..", + "..", + "..", + "..", + "..", + "..", + "src", + "src", + "SourceGenerators" + ); + + static readonly string[] ExpectedShippedRuleIds = + [ + "ZODSGEN001", + "ZODSGEN003", + "ZODSGEN004", + "ZODSGEN005", + "ZODSGEN006", + "ZODSGEN007", + "ZODSGEN008", + "ZODSGEN009", + "ZODSGEN010", + "ZODSGEN011", + "ZODSGEN012", + "ZODSGEN013", + "ZODSGEN014", + "ZODSGEN015", + "ZODSGEN016", + "ZODSGEN017", + "ZODSGEN018", + "ZODSGEN019", + "ZODSGEN020", + "ZODSGEN021", + "ZODSGEN027", + "ZODSGEN028", + "ZODSGEN029", + "ZODSGEN030", + "ZODSGEN031", + "ZODSGEN032", + "ZODSGEN033", + "ZODSGEN034", + "ZODSGEN035", + "ZODSGEN036", + "ZODSASP001", + "ZODSASP002", + "ZODSASP003", + "ZODSASP100", + "ZODSASP101", + ]; + + [Test] + public async Task ShippedReleases_GivenStableRelease_DeclareRelease2_0_0WithEveryDiagnosticId( + CancellationToken cancellationToken + ) + { + // Arrange + var markdown = await ReadReleaseFileAsync("AnalyzerReleases.Shipped.md", cancellationToken); + + // Act + var ruleIds = ReadRuleIds(markdown); + + // Assert + await Assert.That(markdown).Contains("## Release 2.0.0"); + await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedShippedRuleIds)); + } + + [Test] + public async Task UnshippedRelease_GivenStableRelease_DeclaresNoNewRules(CancellationToken cancellationToken) + { + // Arrange + var markdown = await ReadReleaseFileAsync("AnalyzerReleases.Unshipped.md", cancellationToken); + + // Act + var ruleIds = ReadRuleIds(markdown); + + // Assert + await Assert.That(markdown).Contains("### New Rules"); + await Assert.That(markdown).Contains("; Unshipped analyzer release"); + await Assert.That(ruleIds.Length).IsEqualTo(0); + } + + [Test] + public async Task ReleaseFiles_GivenAllDiagnostics_TrackEachRuleIdExactlyOnce(CancellationToken cancellationToken) + { + // Arrange + var shippedRuleIds = ReadRuleIds(await ReadReleaseFileAsync("AnalyzerReleases.Shipped.md", cancellationToken)); + var unshippedRuleIds = ReadRuleIds( + await ReadReleaseFileAsync("AnalyzerReleases.Unshipped.md", cancellationToken) + ); + + // Act + var duplicatedShippedRuleIds = shippedRuleIds + .GroupBy(static id => id, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .Select(static group => group.Key) + .OrderBy(static id => id, StringComparer.Ordinal) + .ToArray(); + var rulesInBothFiles = shippedRuleIds + .Intersect(unshippedRuleIds, StringComparer.Ordinal) + .OrderBy(static id => id, StringComparer.Ordinal) + .ToArray(); + + // Assert + await Assert.That(string.Join(",", duplicatedShippedRuleIds)).IsEqualTo(string.Empty); + await Assert.That(string.Join(",", rulesInBothFiles)).IsEqualTo(string.Empty); + await Assert.That(shippedRuleIds.Length).IsEqualTo(ExpectedShippedRuleIds.Length); + } + + static async Task ReadReleaseFileAsync(string fileName, CancellationToken cancellationToken) => + await File.ReadAllTextAsync(Path.Combine(ReleaseDirectory, fileName), cancellationToken); + + static string[] ReadRuleIds(string markdown) => + [.. RuleIdRegex().Matches(markdown).Select(static match => match.Groups[1].Value)]; + + [GeneratedRegex(@"\|[ \t]*(ZODS[A-Z]{3}\d{3})[ \t]*\|")] + private static partial Regex RuleIdRegex(); + + static string SortedRuleIds(IEnumerable ruleIds) => + string.Join(",", ruleIds.Distinct(StringComparer.Ordinal).OrderBy(static id => id, StringComparer.Ordinal)); +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs new file mode 100644 index 0000000..dff0551 --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -0,0 +1,116 @@ +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; + +namespace ZodSharp.Json.Schema; + +/// +/// Covers JSON Schema import through the System.Text.Json package: the Z.FromJsonSchema extension +/// member, local and external $ref handling, and the exported serializer options. +/// +public class FromJsonSchemaParserTests +{ + const string LocalRefSchema = + /*lang=json,strict*/ + """ + { + "$defs": { "name": { "type": "string", "minLength": 1 } }, + "type": "object", + "properties": { "name": { "$ref": "#/$defs/name" } }, + "required": ["name"] + } + """; + + const string ExternalRefSchema = + /*lang=json,strict*/ + """ + { + "type": "object", + "properties": { "name": { "$ref": "external.json#/$defs/name" } } + } + """; + + [Test] + public async Task FromJsonSchema_GivenLocalDefsRef_ResolvesReference() + { + // Arrange + var schema = Z.FromJsonSchema(LocalRefSchema); + + // Act + var validResult = schema.Validate(new Dictionary { ["name"] = "Ada" }); + var invalidResult = schema.Validate(new Dictionary { ["name"] = "" }); + + // Assert + await Assert.That(validResult.IsSuccess).IsTrue(); + await Assert.That(invalidResult.IsSuccess).IsFalse(); + } + + [Test] + public async Task FromJsonSchema_GivenExternalRef_ThrowsNotSupportedExceptionNamingReference() + { + // Arrange + + // Act + var exception = Assert.Throws(() => Z.FromJsonSchema(ExternalRefSchema)); + + // Assert + await Assert.That(exception!.Message).Contains("External $ref 'external.json#/$defs/name' is not supported"); + await Assert.That(exception.Message).Contains("pre-resolve external schemas before importing"); + } + + [Test] + public async Task Parse_GivenDefinitionWithStringConstraint_ValidatesAgainstConstraint() + { + // Arrange + JsonSchemaDefinition definition = new() { Type = "string", MinLength = 2 }; + + // Act + var schema = FromJsonSchemaParser.Parse(definition); + + // Assert + await Assert.That(schema.Validate("ab").IsSuccess).IsTrue(); + await Assert.That(schema.Validate("a").IsSuccess).IsFalse(); + } + + [Test] + public async Task Parse_GivenNullDefinition_ThrowsArgumentNullException() + { + // Arrange + JsonSchemaDefinition definition = null!; + + // Act + var exception = Assert.Throws(() => FromJsonSchemaParser.Parse(definition)); + + // Assert + await Assert.That(exception!.ParamName).IsEqualTo("schema"); + } + + [Test] + public async Task JsonSchemaSerializerOptions_Default_UsesCamelCasePropertyNames() + { + // Arrange + + // Act + var options = JsonSchemaSerializerOptions.Default; + + // Assert + await Assert.That(options.PropertyNamingPolicy).IsEqualTo(System.Text.Json.JsonNamingPolicy.CamelCase); + } + + [Test] + public async Task JsonSchemaSerializerOptions_GivenRefAndDefs_UsesJsonSchemaKeywordNames() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Ref = "#/$defs/name", + Defs = new Dictionary { ["name"] = new() { Type = "string" } }, + }; + + // Act + var json = System.Text.Json.JsonSerializer.Serialize(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"$ref\""); + await Assert.That(json).Contains("\"$defs\""); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs b/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs new file mode 100644 index 0000000..493d7c9 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.Versioning; + +namespace ZodSharp.Core; + +/// +/// Verifies the multi-targeting guarantee for the core package: every test run resolves a +/// for the core assembly that matches one of the supported target +/// frameworks (net8.0, net9.0, net10.0). +/// +public class MultiTargetingTests +{ + static readonly string[] SupportedTargetFrameworkNames = + [ + ".NETCoreApp,Version=v8.0", + ".NETCoreApp,Version=v9.0", + ".NETCoreApp,Version=v10.0", + ]; + + [Test] + public async Task CoreAssembly_GivenMultiTargetedBuild_DeclaresSupportedTargetFramework() + { + // Arrange + var attribute = typeof(Z).Assembly.GetCustomAttribute(); + + // Act + var frameworkName = attribute?.FrameworkName; + + // Assert + await Assert.That(frameworkName).IsNotNull(); + await Assert.That(SupportedTargetFrameworkNames.Contains(frameworkName!, StringComparer.Ordinal)).IsTrue(); + } +} From 4a0f29dfcd993f202907e7a72eb780304a2a95e9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:03:52 +0100 Subject: [PATCH 5/6] docs: sync the 2.0.0 public surface Update the wiki, README and package READMEs for the stable 2.0.0 release: the package specific JSON Schema namespaces, the removed options type, the external $ref message, the JSON Schema keyword names, the ZODSASP diagnostics table and the shipped ZODSGEN rules. --- README.md | 11 +++++++---- docs/wiki/AspNetCore-Integration.md | 19 +++++++++++++++---- docs/wiki/Cross-Platform-Interop.md | 2 +- docs/wiki/Getting-Started.md | 3 ++- docs/wiki/Guarantees-and-Limitations.md | 4 ++-- docs/wiki/JsonSchema-Export.md | 6 +++--- docs/wiki/JsonSchema-Import.md | 21 +++++++++++++-------- docs/wiki/NewtonsoftJson-Integration.md | 15 +++++++++++---- docs/wiki/Source-Generator-Diagnostics.md | 4 +++- docs/wiki/SystemTextJson-Integration.md | 11 +++++++++-- 10 files changed, 66 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c63915b..1406299 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The fixture generator script is intentionally run via Bun rather than `npx tsx` - **Packages are published under the `Purview.*` IDs.** The core `Purview.ZodSharp` package ships the validator and the source generator, with optional `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` integration packages. - **Targets `net8.0`, `net9.0`, and `net10.0`.** The source generator remains on `netstandard2.0` so it can run in any compiler host. - **System.Text.Json integration.** JSON deserialize-and-validate is available for both major JSON libraries, including validating `JsonConverter` instances. -- **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. +- **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. The import API lives in the JSON integration package's namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`); export stays in the core package. - **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails()`. - **Expanded DataAnnotations support.** `[Length]`, `[MinLength]`, `[MaxLength]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, and more, with structured size failures (`Code`, `Origin`, `Minimum`/`Maximum`, `Inclusive`, `Path`). - **Value-first composition methods.** `.ApplyAnd()`, `.ApplyOr()`, and `.ApplyRefine()` are the supported composition surface. @@ -430,19 +430,22 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ // Serialize with your preferred JSON library // System.Text.Json (add Purview.ZodSharp.SystemTextJson): -using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; var systemTextJson = System.Text.Json.JsonSerializer.Serialize(jsonSchema, JsonSchemaSerializerOptions.Default); // Newtonsoft.Json (add Purview.ZodSharp.NewtonsoftJson): -using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.NewtonsoftJson; var newtonsoftJson = JsonConvert.SerializeObject(jsonSchema, JsonSchemaSerializerOptions.Default); ``` #### Import from JSON Schema (JSON Schema -> Purview.ZodSharp) -Add either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson` to your project, then: +Add either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson` to your project, then import that package's JSON Schema namespace: ```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; // or ZodSharp.JsonSchema.NewtonsoftJson + var jsonSchemaString = @"{ ""type"": ""object"", ""properties"": { diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 5e6b0c3..0520f30 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -196,10 +196,21 @@ The generated `Create` builds a typed `ErrorTypeParameters` instance (validated parameter types and exposed through `ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so `error.Message` already reads `Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the -`409 Conflict` response described below. The analyzers `ZODSASP001`/`ZODSASP002`/`ZODSASP003` -(shipped with the core package) warn when a `MessageFormat` placeholder is not declared in -`Parameters`, when an `[ErrorType]` field's containing class is not `partial`, or when the field is not -`static readonly`. +`409 Conflict` response described below. + +### ErrorType diagnostics + +The `ErrorType` factory, its source generator, and its analyzers ship with the core `Purview.ZodSharp` package: + +| ID | Severity | Meaning | +|---|---|---| +| `ZODSASP001` | Warning | A `MessageFormat` placeholder is not declared in `ErrorType.Parameters` | +| `ZODSASP002` | Warning | The containing type of an `[ErrorType]` field is not declared `partial` | +| `ZODSASP003` | Warning | An `[ErrorType]` field is not declared `static readonly` | +| `ZODSASP100` | Error | Unhandled exception in the `ErrorType` source generator | +| `ZODSASP101` | Error | The `Parameters` of an `[ErrorType]` field could not be extracted | + +`ZODSASP001`–`ZODSASP003` explain why `Create`/`Throw` helpers were not generated; `ZODSASP100`/`ZODSASP101` are fatal generator failures that name the field they failed on. Produces a `409 Conflict` `HttpValidationProblemDetails` with: diff --git a/docs/wiki/Cross-Platform-Interop.md b/docs/wiki/Cross-Platform-Interop.md index 0639fdc..0ae4eb9 100644 --- a/docs/wiki/Cross-Platform-Interop.md +++ b/docs/wiki/Cross-Platform-Interop.md @@ -41,7 +41,7 @@ If the C# output directory is empty, the vitest suite emits a note instructing y The same interop goal is available without fixtures via JSON Schema: - Export: `Z.ToJsonSchema` (core package) → JSON Schema, or `z.toJSONSchema` on the TypeScript side (Zod v4+). -- Import: `Z.FromJsonSchema` (in the System.Text.Json or Newtonsoft.Json package). +- Import: `Z.FromJsonSchema` (in the System.Text.Json or Newtonsoft.Json package — namespace `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). See [JSON Schema Export](JsonSchema-Export.md) and [JSON Schema Import](JsonSchema-Import.md). diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index dbd5eef..5a3bbb0 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -25,7 +25,7 @@ dotnet add package Purview.ZodSharp.AspNetCore - `Purview.ZodSharp.AspNetCore` — failed validation results converted to standard `ProblemDetails` / `HttpValidationProblemDetails` payloads. > [!TIP] -> JSON Schema import (`Z.FromJsonSchema`) is provided by whichever JSON integration package you reference, so pick one. Export (`Z.ToJsonSchema`) lives in the core package. +> JSON Schema import (`Z.FromJsonSchema`) is provided by whichever JSON integration package you reference, so pick one, and import its JSON Schema namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). Export (`Z.ToJsonSchema`) lives in the core package. ## First schema @@ -116,6 +116,7 @@ var result = userSchema.DeserializeAndValidate(json); var jsonSchema = Z.ToJsonSchema(userSchema, new ToJsonSchemaOptions { Title = "User" }); // JSON Schema import (requires an integration package) +// using ZodSharp.JsonSchema.SystemTextJson; // or ZodSharp.JsonSchema.NewtonsoftJson var imported = Z.FromJsonSchema(jsonSchemaString); ``` diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index f58b8c2..23297e4 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -49,11 +49,11 @@ Rules evaluated by the base `Validate` pipeline produce `validation_failed` erro ### JSON Schema import scope -`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options). +`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The reader binds the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), and `Z.ToJsonSchema` writes them with the same names, so exported definitions round-trip. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). ### Referencing both JSON integration packages -`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`. +`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` are **mutually exclusive** integrations — pick the one that matches your JSON library. Both packages can be referenced from the same project **without `extern alias`**, because every JSON Schema import type is declared in a package-specific namespace (`ZodSharp.JsonSchema.SystemTextJson`, `ZodSharp.JsonSchema.NewtonsoftJson`) rather than an identical full name. The deserialize/serialize extension methods remain in the `ZodSharp` namespace in both packages, so import exactly one package namespace per file: importing both makes calls such as `schema.DeserializeAndValidate(json)` or `Z.FromJsonSchema(...)` ambiguous at the call site. ## Custom rules diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index b27ea22..fad7a9f 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -55,15 +55,15 @@ Pick the JSON serializer that matches the integration package you referenced: ```csharp // System.Text.Json (Purview.ZodSharp.SystemTextJson) -using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; var json = System.Text.Json.JsonSerializer.Serialize(jsonSchema, JsonSchemaSerializerOptions.Default); // Newtonsoft.Json (Purview.ZodSharp.NewtonsoftJson) -using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.NewtonsoftJson; var json = JsonConvert.SerializeObject(jsonSchema, JsonSchemaSerializerOptions.Default); ``` -`JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package. +`JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package. Both packages write the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs`, with camelCase for every other keyword, so exported definitions round-trip through the matching import API. ## Round-trip diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index 092fc9b..fced307 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -1,9 +1,10 @@ # JSON Schema Import -Import a JSON Schema into a Purview.ZodSharp schema with `Z.FromJsonSchema`. This API is provided by the JSON integration packages — reference either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson` (both expose the same surface). +Import a JSON Schema into a Purview.ZodSharp schema with `Z.FromJsonSchema`. This API is provided by the JSON integration packages — reference either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson`. The two JSON integrations are mutually exclusive; pick one and import its JSON Schema namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). ```csharp using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; // or ZodSharp.JsonSchema.NewtonsoftJson var jsonSchemaString = """ { @@ -24,17 +25,15 @@ var result = userSchema.Validate(userData); | Signature | Notes | |---|---| -| `IZodSchema FromJsonSchema(string jsonSchema, FromJsonSchemaOptions? options = null)` | parses the JSON string into a `JsonSchemaDefinition`, then into a schema | -| `IZodSchema FromJsonSchema(JsonSchemaDefinition schema, FromJsonSchemaOptions? options = null)` | import from an already-deserialized definition | - -`FromJsonSchemaOptions` is currently an empty placeholder reserved for future options. +| `IZodSchema FromJsonSchema(string jsonSchema)` | parses the JSON string into a `JsonSchemaDefinition`, then into a schema | +| `IZodSchema FromJsonSchema(JsonSchemaDefinition schema)` | import from an already-deserialized definition | > [!NOTE] > `Z.FromJsonSchema` is implemented as a C# 14 extension member on `Z`, so it only exists when a JSON integration package is referenced. `Z.ToJsonSchema` is a real static member on `Z` in the core package. ## Supported keywords -`FromJsonSchemaParser` (namespace `ZodSharp.JsonSchema`) maps: +`FromJsonSchemaParser` (namespace `ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`) maps: - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. @@ -46,8 +45,14 @@ var result = userSchema.Validate(userData); ## Limitations -- `$ref` is supported only for **local** references (`#/...`); external `$ref` targets throw `NotSupportedException`. -- The options type is currently empty; behaviour is fixed by the supported keyword set above. +- `$ref` is supported only for **local** references (`#/...`, for example `#/$defs/Name`). External `$ref` targets throw `NotSupportedException` naming the unsupported reference: + + ```text + External $ref 'external.json#/$defs/name' is not supported. Only local references ('#/...', for example '#/$defs/Name') can be resolved; inline or pre-resolve external schemas before importing. + ``` + + Inline the referenced schema, or move it under the root `$defs`, before importing. Local references may be cyclic — a reference that is still being resolved becomes a lazy schema. +- The reader binds the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs` (plus the draft-07 `definitions`); `Z.ToJsonSchema` writes them back with the same names, so exported definitions round-trip through either integration package. ## Cross-platform reuse diff --git a/docs/wiki/NewtonsoftJson-Integration.md b/docs/wiki/NewtonsoftJson-Integration.md index b170ccb..7541702 100644 --- a/docs/wiki/NewtonsoftJson-Integration.md +++ b/docs/wiki/NewtonsoftJson-Integration.md @@ -1,6 +1,6 @@ # Newtonsoft.Json Integration -The `Purview.ZodSharp.NewtonsoftJson` package adds Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. All extension methods live in the `ZodSharp` namespace. +The `Purview.ZodSharp.NewtonsoftJson` package adds Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. The deserialize/serialize extension methods live in the `ZodSharp` namespace; the JSON Schema import types (and `Z.FromJsonSchema`) live in the `ZodSharp.JsonSchema.NewtonsoftJson` namespace. ## Install @@ -68,7 +68,14 @@ Deserialize/validation failures produce `ValidationError` entries with codes `de ## JSON Schema import -`Z.FromJsonSchema` is available with this package referenced; see [JSON Schema Import](JsonSchema-Import.md). +```csharp +using ZodSharp; +using ZodSharp.JsonSchema.NewtonsoftJson; + +var schema = Z.FromJsonSchema(jsonSchemaString); +``` + +See [JSON Schema Import](JsonSchema-Import.md) for the supported keywords, `$ref` handling, and the `JsonSchemaSerializerOptions` defaults. ## System.Text.Json vs Newtonsoft.Json @@ -82,5 +89,5 @@ Deserialize/validation failures produce `ValidationError` entries with codes `de | Invalid-data exception | `System.Text.Json.JsonException` | `JsonSerializationException` | | JSON plumbing | `JsonElement` | `JToken`/`JObject`/`JArray` | -> [!WARNING] -> Both packages declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `ZodSharp.JsonSchema.FromJsonSchemaParser`, `ZodSharp.JsonSchema.JsonSchemaSerializerOptions`). Referencing both packages in one project creates type ambiguity unless `extern alias` is used — reference one JSON integration package. \ No newline at end of file +> [!NOTE] +> `Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` are mutually exclusive integrations — pick the one that matches your JSON library. Both packages can be referenced from the same project without `extern alias` (their JSON Schema types live in the `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` namespaces), but the deserialize/serialize extension overloads share names, so import exactly one package namespace per file. \ No newline at end of file diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index de5c06b..0cbbac4 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -1,6 +1,6 @@ # Source Generator Diagnostics -The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. All diagnostics below are errors, enabled by default. +The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033` is a warning and the rest are errors. | ID | Meaning | |---|---| @@ -35,6 +35,8 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN035 | The `OnZodValidate` refinement hook is not declared as `partial void OnZodValidate(RefineCtx context)` (wrong modifiers, return type, or parameters) | | ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable Validate()`); implement `OnZodValidate` instead | +IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. + ## Suppressing Diagnostics can be suppressed per-project or per-site with the standard `#pragma warning disable ZODSGEN006` / `NoWarn` mechanisms. Refer to the analyzer's shipped release notes (`AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project) for the canonical catalog. \ No newline at end of file diff --git a/docs/wiki/SystemTextJson-Integration.md b/docs/wiki/SystemTextJson-Integration.md index 7ecbb35..05594fd 100644 --- a/docs/wiki/SystemTextJson-Integration.md +++ b/docs/wiki/SystemTextJson-Integration.md @@ -1,6 +1,6 @@ # System.Text.Json Integration -The `Purview.ZodSharp.SystemTextJson` package adds System.Text.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. All extension methods live in the `ZodSharp` namespace. +The `Purview.ZodSharp.SystemTextJson` package adds System.Text.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. The deserialize/serialize extension methods live in the `ZodSharp` namespace; the JSON Schema import types (and `Z.FromJsonSchema`) live in the `ZodSharp.JsonSchema.SystemTextJson` namespace. ## Install @@ -65,7 +65,14 @@ Deserialize/validation failures produce `ValidationError` entries with codes `de ## JSON Schema import -`Z.FromJsonSchema` is available with this package referenced; see [JSON Schema Import](JsonSchema-Import.md). +```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; + +var schema = Z.FromJsonSchema(jsonSchemaString); +``` + +See [JSON Schema Import](JsonSchema-Import.md) for the supported keywords, `$ref` handling, and the `JsonSchemaSerializerOptions` defaults. ## Comparing with Newtonsoft From f0b3aee3290f0192d889b168a5e8ed22bf1c8346 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 29 Sep 2026 15:11:52 +0100 Subject: [PATCH 6/6] refactor(json): own JSON Schema keyword naming in the integration packages Keep JsonSchemaDefinition free of serializer annotations: the shared POCO stays serializer-agnostic and each integration package maps the JSON Schema keyword names ($schema, $id, $ref, $defs) in its own options - a naming policy for System.Text.Json and a naming strategy for Newtonsoft.Json (which keeps MetadataPropertyHandling.Ignore so the reader does not treat $ref as JSON metadata). Fail with an actionable ArgumentException instead of a NullReferenceException when a converted schema graph contains a null sub-schema, and cover both behaviours with tests. --- docs/wiki/Guarantees-and-Limitations.md | 2 +- docs/wiki/JsonSchema-Export.md | 2 +- docs/wiki/JsonSchema-Import.md | 2 +- .../NewtonsoftJson/FromJsonSchemaParser.cs | 8 ++++ .../JsonSchemaSerializerOptions.cs | 39 ++++++------------- .../SystemTextJson/FromJsonSchemaParser.cs | 8 ++++ .../JsonSchemaSerializerOptions.cs | 27 +++++++++++-- .../JsonSchema/JsonSchemaDefinition.cs | 6 --- .../Json/Schema/FromJsonSchemaParserTests.cs | 30 +++++++++++--- .../Json/Schema/FromJsonSchemaParserTests.cs | 28 +++++++++++-- 10 files changed, 103 insertions(+), 49 deletions(-) diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 23297e4..e557e15 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -49,7 +49,7 @@ Rules evaluated by the base `Validate` pipeline produce `validation_failed` erro ### JSON Schema import scope -`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The reader binds the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), and `Z.ToJsonSchema` writes them with the same names, so exported definitions round-trip. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). +`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The integration packages' `JsonSchemaSerializerOptions` read and write the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), so exported definitions round-trip; the core `JsonSchemaDefinition` type itself stays free of serializer annotations. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). ### Referencing both JSON integration packages diff --git a/docs/wiki/JsonSchema-Export.md b/docs/wiki/JsonSchema-Export.md index fad7a9f..4c62ee8 100644 --- a/docs/wiki/JsonSchema-Export.md +++ b/docs/wiki/JsonSchema-Export.md @@ -63,7 +63,7 @@ using ZodSharp.JsonSchema.NewtonsoftJson; var json = JsonConvert.SerializeObject(jsonSchema, JsonSchemaSerializerOptions.Default); ``` -`JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package. Both packages write the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs`, with camelCase for every other keyword, so exported definitions round-trip through the matching import API. +`JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package and map the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), with camelCase for every other keyword. `JsonSchemaDefinition` itself carries no serializer annotations, so use these options when serializing it — that also keeps exported definitions round-tripping through the matching import API. ## Round-trip diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index fced307..3bd59a6 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -52,7 +52,7 @@ var result = userSchema.Validate(userData); ``` Inline the referenced schema, or move it under the root `$defs`, before importing. Local references may be cyclic — a reference that is still being resolved becomes a lazy schema. -- The reader binds the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs` (plus the draft-07 `definitions`); `Z.ToJsonSchema` writes them back with the same names, so exported definitions round-trip through either integration package. +- The reader binds the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs` (plus the draft-07 `definitions`) through the integration package's `JsonSchemaSerializerOptions`, and the same options write them back, so exported definitions round-trip through either package. `JsonSchemaDefinition` itself carries no serializer annotations, so serialize it with `JsonSchemaSerializerOptions` to get keyword-compliant output. ## Cross-platform reuse diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index eb684a4..f4dfeaa 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -60,6 +60,14 @@ sealed class ConversionContext(JsonSchemaDefinition rootSchema, Dictionary ConvertSchema(JsonSchemaDefinition schema, ConversionContext ctx) { + if (schema is null) + { + throw new ArgumentException( + "The JSON Schema definition contains a null sub-schema, which cannot be converted", + nameof(schema) + ); + } + // Handle $ref if (schema.Ref != null) { diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs index 85afe21..261dcce 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs @@ -4,10 +4,10 @@ namespace ZodSharp.JsonSchema.NewtonsoftJson; /// -/// Resolves the JSON Schema keyword property names ($schema, $id, $ref and -/// $defs) while keeping camelCase naming for every other member. +/// Names the JSON Schema keyword properties with their specification names ($schema, $id, +/// $ref and $defs) and camelCase for every other member. /// -sealed class JsonSchemaKeywordContractResolver : Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver +sealed class JsonSchemaNamingStrategy : CamelCaseNamingStrategy { static readonly Dictionary KeywordNames = new(StringComparer.Ordinal) { @@ -17,28 +17,10 @@ sealed class JsonSchemaKeywordContractResolver : Newtonsoft.Json.Serialization.C ["Defs"] = "$defs", }; - protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) - { - var properties = base.CreateProperties(type, memberSerialization); - - if (type != typeof(ZodSharp.JsonSchema.JsonSchemaDefinition)) - { - return properties; - } - - foreach (var property in properties) - { - if ( - property.UnderlyingName != null - && KeywordNames.TryGetValue(property.UnderlyingName, out var keywordName) - ) - { - property.PropertyName = keywordName; - } - } - - return properties; - } + public override string GetPropertyName(string name, bool hasSpecifiedName) => + KeywordNames.TryGetValue(name, out var keywordName) + ? keywordName + : base.GetPropertyName(name, hasSpecifiedName); } /// @@ -48,14 +30,15 @@ public static class JsonSchemaSerializerOptions { /// /// Default settings for JSON Schema serialization. - /// Uses camelCase property naming and ignores null values. + /// Writes the JSON Schema keyword names ($schema, $id, $ref, $defs) and + /// camelCase for every other member, and ignores null values. /// public static readonly JsonSerializerSettings Default = new() { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.Indented, MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - ContractResolver = new JsonSchemaKeywordContractResolver(), + ContractResolver = new DefaultContractResolver { NamingStrategy = new JsonSchemaNamingStrategy() }, }; /// @@ -66,6 +49,6 @@ public static class JsonSchemaSerializerOptions NullValueHandling = NullValueHandling.Ignore, MissingMemberHandling = MissingMemberHandling.Ignore, MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - ContractResolver = new JsonSchemaKeywordContractResolver(), + ContractResolver = new DefaultContractResolver { NamingStrategy = new JsonSchemaNamingStrategy() }, }; } diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index 7585f5f..7426147 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -56,6 +56,14 @@ sealed class ConversionContext(JsonSchemaDefinition rootSchema, Dictionary ConvertSchema(JsonSchemaDefinition schema, ConversionContext ctx) { + if (schema is null) + { + throw new ArgumentException( + "The JSON Schema definition contains a null sub-schema, which cannot be converted", + nameof(schema) + ); + } + // Handle $ref if (schema.Ref != null) { diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs index 3933fe4..c8443e5 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/JsonSchemaSerializerOptions.cs @@ -2,6 +2,26 @@ namespace ZodSharp.JsonSchema.SystemTextJson; +/// +/// Names the JSON Schema keyword properties with their specification names ($schema, $id, +/// $ref and $defs) and camelCase for every other member. +/// +sealed class JsonSchemaNamingPolicy : JsonNamingPolicy +{ + static readonly Dictionary KeywordNames = new(StringComparer.Ordinal) + { + ["Schema"] = "$schema", + ["Id"] = "$id", + ["Ref"] = "$ref", + ["Defs"] = "$defs", + }; + + public override string ConvertName(string name) => + KeywordNames.TryGetValue(name, out var keywordName) + ? keywordName + : JsonNamingPolicy.CamelCase.ConvertName(name); +} + /// /// Options for JSON serialization/deserialization of JSON Schema using System.Text.Json. /// @@ -9,11 +29,12 @@ public static class JsonSchemaSerializerOptions { /// /// Default settings for JSON Schema serialization. - /// Uses camelCase property naming and ignores null values. + /// Writes the JSON Schema keyword names ($schema, $id, $ref, $defs) and + /// camelCase for every other member, and ignores null values. /// public static readonly JsonSerializerOptions Default = new() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = new JsonSchemaNamingPolicy(), DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, WriteIndented = true, }; @@ -23,7 +44,7 @@ public static class JsonSchemaSerializerOptions /// public static readonly JsonSerializerOptions Reading = new() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = new JsonSchemaNamingPolicy(), DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }; } diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index 1be09e9..bdfa81c 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace ZodSharp.JsonSchema; /// @@ -11,15 +9,12 @@ namespace ZodSharp.JsonSchema; public class JsonSchemaDefinition { /// $schema - The JSON Schema version URI - [JsonPropertyName("$schema")] public string? Schema { get; set; } /// $id - Schema identifier - [JsonPropertyName("$id")] public string? Id { get; set; } /// $ref - Reference to another schema - [JsonPropertyName("$ref")] public string? Ref { get; set; } /// type - The data type (string, number, integer, boolean, object, array, null) @@ -112,7 +107,6 @@ public class JsonSchemaDefinition // ========== Definitions ========== /// $defs - Schema definitions (Draft 2020-12) - [JsonPropertyName("$defs")] public Dictionary? Defs { get; set; } /// definitions - Schema definitions (Draft 07 and earlier) diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index 055b045..e6ff327 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -86,18 +86,36 @@ public async Task Parse_GivenNullDefinition_ThrowsArgumentNullException() } [Test] - public async Task JsonSchemaSerializerOptions_Default_UsesCamelCasePropertyNames() + public async Task JsonSchemaSerializerOptions_Default_UsesJsonSchemaKeywordAndCamelCaseNames() { // Arrange + JsonSchemaDefinition definition = new() { MinLength = 3 }; // Act - var options = JsonSchemaSerializerOptions.Default; - var usesCamelCaseResolver = - options.ContractResolver is Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(definition, JsonSchemaSerializerOptions.Default); + + // Assert + await Assert.That(json).Contains("\"minLength\""); + await Assert + .That(JsonSchemaSerializerOptions.Default.Formatting) + .IsEqualTo(Newtonsoft.Json.Formatting.Indented); + } + + [Test] + public async Task Parse_GivenNullSubSchema_ThrowsArgumentException() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "object", + Properties = new Dictionary { ["name"] = null! }, + }; + + // Act + var exception = Assert.Throws(() => FromJsonSchemaParser.Parse(definition)); // Assert - await Assert.That(usesCamelCaseResolver).IsTrue(); - await Assert.That(options.Formatting).IsEqualTo(Newtonsoft.Json.Formatting.Indented); + await Assert.That(exception!.Message).Contains("null sub-schema"); } [Test] diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs index dff0551..b0b4fdd 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserTests.cs @@ -85,15 +85,37 @@ public async Task Parse_GivenNullDefinition_ThrowsArgumentNullException() } [Test] - public async Task JsonSchemaSerializerOptions_Default_UsesCamelCasePropertyNames() + public async Task JsonSchemaSerializerOptions_Default_UsesJsonSchemaKeywordAndCamelCaseNames() { // Arrange + var namingPolicy = JsonSchemaSerializerOptions.Default.PropertyNamingPolicy; // Act - var options = JsonSchemaSerializerOptions.Default; + var refName = namingPolicy!.ConvertName(nameof(JsonSchemaDefinition.Ref)); + var defsName = namingPolicy.ConvertName(nameof(JsonSchemaDefinition.Defs)); + var minLengthName = namingPolicy.ConvertName(nameof(JsonSchemaDefinition.MinLength)); // Assert - await Assert.That(options.PropertyNamingPolicy).IsEqualTo(System.Text.Json.JsonNamingPolicy.CamelCase); + await Assert.That(refName).IsEqualTo("$ref"); + await Assert.That(defsName).IsEqualTo("$defs"); + await Assert.That(minLengthName).IsEqualTo("minLength"); + } + + [Test] + public async Task Parse_GivenNullSubSchema_ThrowsArgumentException() + { + // Arrange + JsonSchemaDefinition definition = new() + { + Type = "object", + Properties = new Dictionary { ["name"] = null! }, + }; + + // Act + var exception = Assert.Throws(() => FromJsonSchemaParser.Parse(definition)); + + // Assert + await Assert.That(exception!.Message).Contains("null sub-schema"); } [Test]