From 4c7b5ce8bf385b2557ef3a78938b64b8ae23d7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Mon, 7 Sep 2026 22:31:21 +0200 Subject: [PATCH 1/5] Let scalar to CLR type mapping be overridden from a settings file The generator mapped GraphQL scalars to CLR types through a hardcoded table with no way for a user to influence it: a schema whose DateTime you want as DateTime rather than DateTimeOffset, or whose BigInt you want as long rather than a generated CustomScalar wrapper, had no recourse. Add a json settings file (--config) that carries every existing option plus a scalarMappings table. An explicitly passed command line option always wins over the file, so flag-only invocations - including scripts/regenerate-test-clients.ps1 - behave exactly as before. The mapping table moves from a static Helpers.TypeMapping onto GeneratorSettings.Current, copied fresh per run so overrides cannot leak between runs. Its two consumers read it through Helpers.EffectiveTypeMapping: BaseType.GetCoreType for the emitted type name, and Schema.GetCustomScalars, which treats absence from the table as "generate a CustomScalar for this scalar". So mapping a scalar to a simple type also suppresses its generated class, and mapping it to null opts a built-in scalar out into a CustomScalar. Targets are validated against an allow-list of simple BCL types rather than accepting any string. CoreType.CSharpType is not decorative - it drives UseSharpNoneNull and InputFactoryClassTemplate - and a target that does not resolve to a real Type leaves it null, which silently emits the wrong nullability and the wrong input factory overload instead of failing. Bad targets now fail the run with the supported list in the message. --- CLAUDE.md | 3 + README.md | 50 +++++ src/Linq2GraphQL.Generator/ClientGenerator.cs | 16 +- src/Linq2GraphQL.Generator/GeneratorConfig.cs | 67 ++++++ .../GeneratorConfigurationException.cs | 17 ++ .../GeneratorSettings.cs | 15 +- .../GraphQLSchema/Helpers.cs | 144 +++++++++++- .../GraphQLSchema/RootSchema.cs | 2 +- .../GraphQLSchema/Schema.cs | 2 +- src/Linq2GraphQL.Generator/Program.cs | 116 ++++++++-- .../Linq2GraphQL.Tests.csproj | 1 + test/Linq2GraphQL.Tests/ScalarMappingTests.cs | 210 ++++++++++++++++++ test/Linq2GraphQL.Tests/packages.lock.json | 21 ++ 13 files changed, 625 insertions(+), 39 deletions(-) create mode 100644 src/Linq2GraphQL.Generator/GeneratorConfig.cs create mode 100644 src/Linq2GraphQL.Generator/GeneratorConfigurationException.cs create mode 100644 test/Linq2GraphQL.Tests/ScalarMappingTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 44033b2e..856301ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,9 @@ Key details that bite: `GeneratorSettings.Current.Nullable` is ambient static state read from inside templates; the nullable and non-nullable clients differ mainly in nullable annotations and `#pragma warning disable CS8618`. +`GeneratorSettings.Current.TypeMapping` is the GraphQL-scalar-to-CLR-type table for the run: `Helpers.DefaultTypeMapping` with any `scalarMappings` from the `--config` settings file applied on top (`GeneratorConfig`). It is read in exactly two places - `BaseType.GetCoreType` for the emitted type name and `Schema.GetCustomScalars`, which treats absence from the table as "generate a `CustomScalar` class for this scalar" - so mapping a scalar to a simple type also suppresses its generated class, and mapping it to `null` does the reverse. Override targets are validated against `Helpers.SupportedTargetTypes`, an allow-list, because a null `CoreType.CSharpType` silently changes nullability and the input-factory template rather than failing. + + ## Tests (test/) `Linq2GraphQL.Tests` is xUnit + Shouldly + Moq. It spins up the real GraphQL server in-process with `WebApplicationFactory` — `Linq2GraphQL.TestServer` is HotChocolate over the POCOs in `TestServer.Shared`, and `TestServerNullable` is the same schema for the nullable client. `SampleClientFixture` / `SampleClientNullableFixture` wire the generated client to that in-memory host (safe mode on, SSE subscriptions), and test classes take them via `IClassFixture<>`. So most tests are end-to-end: an assertion failure can come from the expression parser, the query text, or the server's own resolvers. diff --git a/README.md b/README.md index c26bb18c..0087269b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Usage: -es --enum-strategy If AddUnknownOption all enums will have an additional Unknown option -nu --nullabel Nullable client [default: false] -d --deprecated Include Deprecated as Obsolete + -cf --config Json settings file, see Configuration file below As an example: @@ -78,6 +79,55 @@ As an example: Would generate a client from url *https://spacex-production.up.railway.app/* with the name *SpaceXClient* in the namespace *SpaceX* to folder *Generated* +## Configuration file + +Every option above can also be set in a json file, which keeps a long command line out of your build +scripts and gives you somewhere to check the settings in: + + Linq2GraphQL --config linq2graphql.json + +```json +{ + "endpoint": "https://spacex-production.up.railway.app/", + "client": "SpaceXClient", + "namespace": "SpaceX", + "output": "Generated", + "nullable": false, + "subscriptions": false, + "scalarMappings": { + "DateTime": "System.DateTime", + "BigInt": "long", + "Json": null + } +} +``` + +Everything in the file is optional, and an option you pass explicitly on the command line always wins +over the file - so you can keep the shared settings in the file and override one of them for a single +run: + + Linq2GraphQL --config linq2graphql.json -o="SomewhereElse" + +### Mapping scalars to your own types + +By default the generator maps the well known GraphQL scalars to CLR types (`Int` to `int`, `DateTime` +to `DateTimeOffset`, and so on) and generates a `CustomScalar` class for every scalar it does not +recognise. `scalarMappings` lets you override both halves of that: + +* Map a scalar to a **simple type** and it is emitted as that type - no `CustomScalar` class is + generated for it. `"BigInt": "long"` gives you `public long? Id { get; set; }`. +* Change an existing mapping the same way. `"DateTime": "System.DateTime"` emits `DateTime` instead + of the default `DateTimeOffset`. +* Map a scalar to **null** to opt it out of the built-in mapping, so a `CustomScalar` class is + generated for it instead and you control the conversion yourself. + +Scalar names are matched case insensitively. The target must be one of the supported simple types - +`bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, +`double`, `decimal`, `string`, `Guid`, `Uri`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, +`TimeSpan` - written either as the C# keyword (`long`), the type name (`Int64`) or its full name +(`System.Int64`). Anything else fails the run with an error rather than generating a client that does +not compile. + ## Add Nuget Latest diff --git a/src/Linq2GraphQL.Generator/ClientGenerator.cs b/src/Linq2GraphQL.Generator/ClientGenerator.cs index 55ec6eb5..e6731c43 100644 --- a/src/Linq2GraphQL.Generator/ClientGenerator.cs +++ b/src/Linq2GraphQL.Generator/ClientGenerator.cs @@ -16,8 +16,11 @@ public class ClientGenerator( bool includeSubscriptions, EnumGeneratorStrategy enumGeneratorStrategy, bool nullable, - bool includeDeprecated) + bool includeDeprecated, + IReadOnlyDictionary scalarMappings = null) { + private static readonly Dictionary EmptyScalarMappings = new(); + private readonly List entries = new(); private void AddFile(string directory, string fileName, string content) @@ -74,7 +77,16 @@ public List Generate(string schemaJson) { entries.Clear(); - GeneratorSettings.Current = new GeneratorSettings { Nullable = nullable }; + var typeMapping = Helpers.CreateTypeMapping(); + Helpers.ApplyScalarMappings(typeMapping, scalarMappings); + GeneratorSettings.Current = new GeneratorSettings { Nullable = nullable, TypeMapping = typeMapping }; + + foreach (var scalarMapping in scalarMappings ?? EmptyScalarMappings) + { + Console.WriteLine(scalarMapping.Value == null + ? $"Scalar {scalarMapping.Key} opted out of type mapping, generating a CustomScalar for it" + : $"Scalar {scalarMapping.Key} mapped to {scalarMapping.Value.Value.Name}"); + } var rootSchema = JsonSerializer.Deserialize(schemaJson, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); diff --git a/src/Linq2GraphQL.Generator/GeneratorConfig.cs b/src/Linq2GraphQL.Generator/GeneratorConfig.cs new file mode 100644 index 00000000..ac75fc60 --- /dev/null +++ b/src/Linq2GraphQL.Generator/GeneratorConfig.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Linq2GraphQL.Generator; + +/// +/// The settings file (--config). Every value is optional and acts as the baseline for the +/// command line: an explicitly passed flag always wins over the corresponding value here. +/// +public class GeneratorConfig +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public string Endpoint { get; set; } + + public string Output { get; set; } + + public string Namespace { get; set; } + + public string Client { get; set; } + + public string Token { get; set; } + + public bool? Subscriptions { get; set; } + + public string EnumStrategy { get; set; } + + public bool? Nullable { get; set; } + + public bool? Deprecated { get; set; } + + /// + /// GraphQL scalar name to simple CLR type, e.g. "DateTime": "System.DateTime". The + /// target must be one of . A null value opts the + /// scalar out of the built-in mapping, so a CustomScalar class is generated for it instead. + /// + public Dictionary ScalarMappings { get; set; } + + public static GeneratorConfig Load(string path) + { + var fullPath = Path.GetFullPath(path, Environment.CurrentDirectory); + + if (!File.Exists(fullPath)) + { + throw new GeneratorConfigurationException($"Config file not found: {fullPath}"); + } + + Console.WriteLine($"Reading configuration from {fullPath}"); + + GeneratorConfig config; + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(fullPath), SerializerOptions); + } + catch (JsonException ex) + { + throw new GeneratorConfigurationException($"Config file {fullPath} is not valid JSON: {ex.Message}", ex); + } + + return config ?? new GeneratorConfig(); + } +} diff --git a/src/Linq2GraphQL.Generator/GeneratorConfigurationException.cs b/src/Linq2GraphQL.Generator/GeneratorConfigurationException.cs new file mode 100644 index 00000000..7cf06459 --- /dev/null +++ b/src/Linq2GraphQL.Generator/GeneratorConfigurationException.cs @@ -0,0 +1,17 @@ +namespace Linq2GraphQL.Generator; + +/// +/// Thrown when the generator configuration - the settings file or the command line that layers +/// over it - is invalid. These are user mistakes, so Program reports the message on its +/// own rather than dumping a stack trace. +/// +public class GeneratorConfigurationException : Exception +{ + public GeneratorConfigurationException(string message) : base(message) + { + } + + public GeneratorConfigurationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Linq2GraphQL.Generator/GeneratorSettings.cs b/src/Linq2GraphQL.Generator/GeneratorSettings.cs index 87245481..efb2fe70 100644 --- a/src/Linq2GraphQL.Generator/GeneratorSettings.cs +++ b/src/Linq2GraphQL.Generator/GeneratorSettings.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace Linq2GraphQL.Generator { public class GeneratorSettings @@ -11,7 +5,12 @@ public class GeneratorSettings public static GeneratorSettings Current { get; set; } public bool Nullable { get; set; } - } - + /// + /// GraphQL scalar name to CLR type for this run: the built-in table with any + /// scalarMappings overrides from the settings file applied on top. + /// + public Dictionary TypeMapping { get; set; } = + Helpers.CreateTypeMapping(); + } } diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs index 8f8a23be..b8847080 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs @@ -104,8 +104,12 @@ internal static string SafeVariableName(string name) "while",]; - public static readonly Dictionary TypeMapping = - new(StringComparer.InvariantCultureIgnoreCase) + /// + /// The built-in GraphQL scalar to CLR type mapping. Copied per generator run so that user + /// supplied overrides never leak between runs - see . + /// + public static readonly IReadOnlyDictionary DefaultTypeMapping = + new Dictionary(StringComparer.InvariantCultureIgnoreCase) { { "Int", new ValueTuple("int", typeof(int)) }, { "Float", new ValueTuple("double", typeof(double)) }, @@ -124,4 +128,138 @@ internal static string SafeVariableName(string name) { "LocalDate", new ValueTuple("DateOnly", typeof(DateOnly)) }, { "LocalTime", new ValueTuple("TimeOnly", typeof(TimeOnly)) }, }; -} \ No newline at end of file + + /// + /// The simple CLR types a scalar may be mapped to. Deliberately an allow-list: a mapping target + /// has to resolve to a real , because drives + /// nullability and the input factory template. Keyed by every spelling we accept, valued with + /// the canonical name to emit. + /// + public static readonly IReadOnlyDictionary SupportedTargetTypes = + BuildSupportedTargetTypes(); + + /// + /// Names of the supported mapping targets, canonically spelled, for error messages. + /// + public static IEnumerable SupportedTargetTypeNames => + SupportedTargetTypes.Values.Select(e => e.Name).Distinct().OrderBy(e => e, StringComparer.Ordinal); + + private static Dictionary BuildSupportedTargetTypes() + { + var result = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + void Add(string emittedName, Type type) + { + var mapping = new ValueTuple(emittedName, type); + result[emittedName] = mapping; + result[type.Name] = mapping; + result[type.FullName] = mapping; + } + + Add("bool", typeof(bool)); + Add("byte", typeof(byte)); + Add("sbyte", typeof(sbyte)); + Add("char", typeof(char)); + Add("short", typeof(short)); + Add("ushort", typeof(ushort)); + Add("int", typeof(int)); + Add("uint", typeof(uint)); + Add("long", typeof(long)); + Add("ulong", typeof(ulong)); + Add("float", typeof(float)); + Add("double", typeof(double)); + Add("decimal", typeof(decimal)); + Add("string", typeof(string)); + Add("Guid", typeof(Guid)); + Add("Uri", typeof(Uri)); + Add("DateTime", typeof(DateTime)); + Add("DateTimeOffset", typeof(DateTimeOffset)); + Add("DateOnly", typeof(DateOnly)); + Add("TimeOnly", typeof(TimeOnly)); + Add("TimeSpan", typeof(TimeSpan)); + + return result; + } + + /// + /// A fresh, mutable copy of for a single generator run. + /// + public static Dictionary CreateTypeMapping() + { + return new Dictionary(DefaultTypeMapping, + StringComparer.InvariantCultureIgnoreCase); + } + + /// + /// Turns raw scalarMappings config values into resolved mappings, failing fast on an + /// unsupported target type. A null or empty value means "opt this scalar out of the built-in + /// mapping", which makes the generator emit a CustomScalar class for it instead. + /// + public static Dictionary ResolveScalarMappings( + IReadOnlyDictionary scalarMappings) + { + var result = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + if (scalarMappings == null) + { + return result; + } + + foreach (var (scalarName, targetTypeName) in scalarMappings) + { + if (string.IsNullOrWhiteSpace(scalarName)) + { + throw new GeneratorConfigurationException("A scalar mapping must have a non-empty scalar name."); + } + + if (string.IsNullOrWhiteSpace(targetTypeName)) + { + result[scalarName.Trim()] = null; + continue; + } + + if (!SupportedTargetTypes.TryGetValue(targetTypeName.Trim(), out var mapping)) + { + throw new GeneratorConfigurationException( + $"Unsupported target type '{targetTypeName}' for scalar '{scalarName}'. Supported target types are: " + + $"{string.Join(", ", SupportedTargetTypeNames)}. " + + "Use null to generate a CustomScalar class for the scalar instead."); + } + + result[scalarName.Trim()] = mapping; + } + + return result; + } + + /// + /// Applies resolved overrides on top of a type mapping table, in place. + /// + public static void ApplyScalarMappings(Dictionary typeMapping, + IReadOnlyDictionary overrides) + { + if (overrides == null) + { + return; + } + + foreach (var (scalarName, mapping) in overrides) + { + if (mapping == null) + { + typeMapping.Remove(scalarName); + } + else + { + typeMapping[scalarName] = mapping.Value; + } + } + } + + /// + /// The type mapping in force for the current run, falling back to the defaults when no + /// generator run has been started. + /// + internal static IReadOnlyDictionary EffectiveTypeMapping => + GeneratorSettings.Current?.TypeMapping ?? DefaultTypeMapping; +} diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs index a6675c27..688234bc 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs @@ -302,7 +302,7 @@ public CoreType GetCoreType() result.Lists.Reverse(); - if (Helpers.TypeMapping.TryGetValue(result.BaseType.Name, out var typeMapping)) + if (Helpers.EffectiveTypeMapping.TryGetValue(result.BaseType.Name, out var typeMapping)) { result.CSharpTypeName = typeMapping.Name; result.CSharpType = typeMapping.type; diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs index f4b1635b..5fd03583 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs @@ -68,7 +68,7 @@ public List GetEnums() public List GetCustomScalars() { - var mappers = Helpers.TypeMapping; + var mappers = Helpers.EffectiveTypeMapping; return GetAllTypesExceptSystemTypes().Where(e => e.Kind == TypeKind.Scalar && !mappers.ContainsKey(e.Name)) .ToList(); } diff --git a/src/Linq2GraphQL.Generator/Program.cs b/src/Linq2GraphQL.Generator/Program.cs index 4ff05640..cc1bfcd4 100644 --- a/src/Linq2GraphQL.Generator/Program.cs +++ b/src/Linq2GraphQL.Generator/Program.cs @@ -1,22 +1,23 @@ using System.CommandLine; +using System.CommandLine.Parsing; namespace Linq2GraphQL.Generator; internal class Program { - private static async Task Main(string[] args) + private static async Task Main(string[] args) { var uriArgument = new Argument("endpoint", "Endpoint of the GraphQL service") { - Arity = ArgumentArity.ExactlyOne + // Optional, because a config file may supply it instead. + Arity = ArgumentArity.ZeroOrOne }; - var outputFolder = new Option(new[] { "--output", "-o" }, () => "Linq2GraphQL_Generated", - "Output folder, relative to current location"); - var namespaceName = new Option(new[] { "--namespace", "-n" }, () => "YourNamespace", - "Namespace of generated classes"); - var clientName = new Option(new[] { "--client", "-c" }, () => "GraphQLClient", - "Name of the generated client"); + var configFile = new Option(new[] { "--config", "-cf" }, + "Json settings file. Every setting in it is optional and is overridden by an explicitly passed option"); + var outputFolder = new Option(new[] { "--output", "-o" }, "Output folder, relative to current location"); + var namespaceName = new Option(new[] { "--namespace", "-n" }, "Namespace of generated classes"); + var clientName = new Option(new[] { "--client", "-c" }, "Name of the generated client"); var authToken = new Option(new[] { "--token", "-t" }, "Bearertoken for authentication"); var includeSubscriptions = new Option(new[] { "--subscriptions", "-s" }, "Include subscriptions"); var enumStrategy = new Option(new[] { "--enum-strategy", "-es" }, "Enum strategy"); @@ -26,6 +27,7 @@ private static async Task Main(string[] args) var rootCommand = new RootCommand("Generate GraphQL client") { uriArgument, + configFile, outputFolder, namespaceName, clientName, @@ -39,26 +41,92 @@ private static async Task Main(string[] args) rootCommand.SetHandler(async context => { var result = context.ParseResult; - var uriValue = result.GetValueForArgument(uriArgument); - var outputFolderValue = result.GetValueForOption(outputFolder); - var namespaceValue = result.GetValueForOption(namespaceName); - var clientNameValue = result.GetValueForOption(clientName); - var authTokenValue = result.GetValueForOption(authToken); - var includeSubscriptionsValue = result.GetValueForOption(includeSubscriptions); - var enumStrategyValue = result.GetValueForOption(enumStrategy); - var nullableValue = result.GetValueForOption(nullable); - var deprecatedValue = result.GetValueForOption(includeDeprecated); - - await GenerateClientAsync(uriValue, outputFolderValue, namespaceValue, clientNameValue, - includeSubscriptionsValue, authTokenValue, enumStrategyValue, nullableValue, deprecatedValue); + + try + { + var configPath = result.GetValueForOption(configFile); + var config = configPath == null ? new GeneratorConfig() : GeneratorConfig.Load(configPath); + + // Fail before touching the network if a mapping target is bad. + var scalarMappings = Helpers.ResolveScalarMappings(config.ScalarMappings); + + var uriValue = ResolveEndpoint(result, uriArgument, config.Endpoint); + var outputFolderValue = Resolve(result, outputFolder, config.Output, "Linq2GraphQL_Generated"); + var namespaceValue = Resolve(result, namespaceName, config.Namespace, "YourNamespace"); + var clientNameValue = Resolve(result, clientName, config.Client, "GraphQLClient"); + var authTokenValue = Resolve(result, authToken, config.Token, null); + var enumStrategyValue = Resolve(result, enumStrategy, config.EnumStrategy, null); + var includeSubscriptionsValue = Resolve(result, includeSubscriptions, config.Subscriptions, false); + var nullableValue = Resolve(result, nullable, config.Nullable, false); + var deprecatedValue = Resolve(result, includeDeprecated, config.Deprecated, false); + + await GenerateClientAsync(uriValue, outputFolderValue, namespaceValue, clientNameValue, + includeSubscriptionsValue, authTokenValue, enumStrategyValue, nullableValue, deprecatedValue, + scalarMappings); + } + catch (GeneratorConfigurationException ex) + { + Console.Error.WriteLine(ex.Message); + context.ExitCode = 1; + } } ); - await rootCommand.InvokeAsync(args); + return await rootCommand.InvokeAsync(args); + } + + /// + /// An explicitly passed option always wins over the config file, which in turn wins over the + /// built-in default. Options deliberately carry no default value factory so that + /// can tell "not passed" from "passed the default". + /// + private static T Resolve(ParseResult result, Option option, T configValue, T defaultValue) + where T : class + { + if (result.FindResultFor(option) != null) + { + return result.GetValueForOption(option); + } + + return configValue ?? defaultValue; + } + + private static T Resolve(ParseResult result, Option option, T? configValue, T defaultValue) + where T : struct + { + if (result.FindResultFor(option) != null) + { + return result.GetValueForOption(option); + } + + return configValue ?? defaultValue; + } + + private static Uri ResolveEndpoint(ParseResult result, Argument argument, string configEndpoint) + { + var uriValue = result.GetValueForArgument(argument); + if (uriValue != null) + { + return uriValue; + } + + if (string.IsNullOrWhiteSpace(configEndpoint)) + { + throw new GeneratorConfigurationException( + "No endpoint given. Pass it as the first argument or set \"endpoint\" in the config file."); + } + + if (!Uri.TryCreate(configEndpoint, UriKind.Absolute, out var configUri)) + { + throw new GeneratorConfigurationException($"\"endpoint\" is not a valid absolute uri: {configEndpoint}"); + } + + return configUri; } private static async Task GenerateClientAsync(Uri uri, string outputFolder, string namespaceName, string name, - bool includeSubscriptions, string authToken, string enumStrategy, bool nullable, bool includeDeprecated) + bool includeSubscriptions, string authToken, string enumStrategy, bool nullable, bool includeDeprecated, + IReadOnlyDictionary scalarMappings) { var enumStrat = enumStrategy != null && enumStrategy.Equals("AddUnknownOption", StringComparison.InvariantCultureIgnoreCase) @@ -66,7 +134,7 @@ private static async Task GenerateClientAsync(Uri uri, string outputFolder, stri : EnumGeneratorStrategy.FailIfMissing; var generator = new ClientGenerator(namespaceName, name, includeSubscriptions, enumStrat, nullable, - includeDeprecated); + includeDeprecated, scalarMappings); var entries = await generator.GenerateAsync(uri, authToken); var outputPath = Path.GetFullPath(outputFolder, Environment.CurrentDirectory); @@ -83,4 +151,4 @@ private static async Task GenerateClientAsync(Uri uri, string outputFolder, stri } } } -} \ No newline at end of file +} diff --git a/test/Linq2GraphQL.Tests/Linq2GraphQL.Tests.csproj b/test/Linq2GraphQL.Tests/Linq2GraphQL.Tests.csproj index 28208765..3538c058 100644 --- a/test/Linq2GraphQL.Tests/Linq2GraphQL.Tests.csproj +++ b/test/Linq2GraphQL.Tests/Linq2GraphQL.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs new file mode 100644 index 00000000..c394a23a --- /dev/null +++ b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using Linq2GraphQL.Generator; +using Shouldly; + +namespace Linq2GraphQL.Tests; + +/// +/// Covers the scalarMappings config: overriding how a GraphQL scalar maps to a CLR type, and the +/// knock-on effect that a mapped scalar no longer gets a CustomScalar class generated for it. +/// +public class ScalarMappingTests +{ + /// + /// A hand-written introspection response with three scalars: one mapped by default + /// (DateTime -> DateTimeOffset) and two that are not (BigInt, Json). + /// + private const string SchemaJson = """ + { + "data": { + "__schema": { + "queryType": { "name": "Query" }, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "fields": [ + { "name": "thing", "args": [], "type": { "kind": "OBJECT", "name": "Thing", "ofType": null } } + ] + }, + { + "kind": "OBJECT", + "name": "Thing", + "fields": [ + { "name": "id", "args": [], "type": { "kind": "SCALAR", "name": "BigInt", "ofType": null } }, + { "name": "when", "args": [], "type": { "kind": "SCALAR", "name": "DateTime", "ofType": null } }, + { "name": "payload", "args": [], "type": { "kind": "SCALAR", "name": "Json", "ofType": null } } + ] + }, + { "kind": "SCALAR", "name": "BigInt" }, + { "kind": "SCALAR", "name": "DateTime" }, + { "kind": "SCALAR", "name": "Json" }, + { "kind": "SCALAR", "name": "String" } + ] + } + } + } + """; + + private static List Generate(Dictionary? scalarMappings = null) + { + var resolved = Helpers.ResolveScalarMappings(scalarMappings); + var generator = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false, resolved); + return generator.Generate(SchemaJson); + } + + private static string ThingType(List entries) + { + return entries.Single(e => e.DirectoryName == "Types" && e.FileName == "Thing.cs").Content; + } + + private static IEnumerable ScalarFiles(List entries) + { + return entries.Where(e => e.DirectoryName == "Scalars").Select(e => e.FileName); + } + + [Fact] + public void NoOverrides_UsesBuiltInMapping() + { + var entries = Generate(); + + ThingType(entries).ShouldContain("DateTimeOffset? When"); + ScalarFiles(entries).ShouldBe(new[] { "BigInt.cs", "Json.cs" }, ignoreOrder: true); + } + + [Fact] + public void Override_ChangesAnAlreadyMappedScalar() + { + var entries = Generate(new Dictionary { ["DateTime"] = "System.DateTime" }); + + ThingType(entries).ShouldContain("DateTime? When"); + ThingType(entries).ShouldNotContain("DateTimeOffset"); + } + + [Fact] + public void Override_MapsACustomScalarToASimpleType_AndStopsGeneratingItsClass() + { + var entries = Generate(new Dictionary { ["BigInt"] = "long" }); + + ThingType(entries).ShouldContain("long? Id"); + ScalarFiles(entries).ShouldBe(new[] { "Json.cs" }); + } + + [Fact] + public void Override_ToNull_OptsAScalarOutAndGeneratesACustomScalarInstead() + { + var entries = Generate(new Dictionary { ["DateTime"] = null! }); + + ThingType(entries).ShouldContain("DateTime When"); + ThingType(entries).ShouldNotContain("DateTimeOffset"); + ScalarFiles(entries).ShouldBe(new[] { "BigInt.cs", "DateTime.cs", "Json.cs" }, ignoreOrder: true); + } + + [Theory] + [InlineData("long")] + [InlineData("Int64")] + [InlineData("System.Int64")] + [InlineData("LONG")] + public void TargetType_AcceptsEverySupportedSpelling(string targetType) + { + var entries = Generate(new Dictionary { ["BigInt"] = targetType }); + + ThingType(entries).ShouldContain("long? Id"); + } + + [Fact] + public void ScalarName_IsMatchedCaseInsensitively() + { + var entries = Generate(new Dictionary { ["bigint"] = "long" }); + + ThingType(entries).ShouldContain("long? Id"); + ScalarFiles(entries).ShouldBe(new[] { "Json.cs" }); + } + + [Fact] + public void UnsupportedTargetType_FailsWithAHelpfulMessage() + { + var ex = Should.Throw(() => + Helpers.ResolveScalarMappings(new Dictionary { ["Money"] = "My.App.Money" })); + + ex.Message.ShouldContain("My.App.Money"); + ex.Message.ShouldContain("Money"); + ex.Message.ShouldContain("DateTimeOffset"); + } + + [Fact] + public void Overrides_DoNotLeakBetweenRuns() + { + Generate(new Dictionary { ["BigInt"] = "long" }); + + var entries = Generate(); + + ThingType(entries).ShouldContain("BigInt Id"); + ScalarFiles(entries).ShouldBe(new[] { "BigInt.cs", "Json.cs" }, ignoreOrder: true); + Helpers.DefaultTypeMapping.ContainsKey("BigInt").ShouldBeFalse(); + } + + [Fact] + public void ConfigFile_RoundTripsEverySetting() + { + var json = """ + { + "endpoint": "https://example.com/graphql", + "output": "Generated", + "namespace": "My.Ns", + "client": "MyClient", + "nullable": true, + "subscriptions": true, + "scalarMappings": { + "DateTime": "System.DateTime", + "BigInt": "long", + "Json": null + } + } + """; + + var config = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + config.ShouldNotBeNull(); + config.Endpoint.ShouldBe("https://example.com/graphql"); + config.Namespace.ShouldBe("My.Ns"); + config.Client.ShouldBe("MyClient"); + config.Nullable.ShouldBe(true); + config.Subscriptions.ShouldBe(true); + config.Deprecated.ShouldBeNull(); + + var resolved = Helpers.ResolveScalarMappings(config.ScalarMappings); + resolved["DateTime"]!.Value.Name.ShouldBe("DateTime"); + resolved["BigInt"]!.Value.Name.ShouldBe("long"); + resolved["Json"].ShouldBeNull(); + } + + [Fact] + public void ConfigFile_MissingFile_FailsWithAHelpfulMessage() + { + var missing = Path.Combine(Path.GetTempPath(), $"linq2graphql-missing-{Guid.NewGuid():N}.json"); + + var ex = Should.Throw(() => GeneratorConfig.Load(missing)); + + ex.Message.ShouldContain("not found"); + } + + [Fact] + public void ConfigFile_InvalidJson_FailsWithAHelpfulMessage() + { + var path = Path.Combine(Path.GetTempPath(), $"linq2graphql-bad-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, "{ not json"); + + try + { + var ex = Should.Throw(() => GeneratorConfig.Load(path)); + ex.Message.ShouldContain("not valid JSON"); + } + finally + { + File.Delete(path); + } + } +} diff --git a/test/Linq2GraphQL.Tests/packages.lock.json b/test/Linq2GraphQL.Tests/packages.lock.json index b67739af..6d0f3358 100644 --- a/test/Linq2GraphQL.Tests/packages.lock.json +++ b/test/Linq2GraphQL.Tests/packages.lock.json @@ -890,6 +890,15 @@ "Websocket.Client": "[5.3.0, )" } }, + "linq2graphql.generator": { + "type": "Project", + "dependencies": { + "Linq2GraphQL.Client": "[1.0.0, )", + "Macross.Json.Extensions": "[3.0.0, )", + "System.CodeDom": "[10.0.11, )", + "System.CommandLine": "[2.0.0-beta4.22272.1, )" + } + }, "linq2graphql.testclient": { "type": "Project", "dependencies": { @@ -976,6 +985,12 @@ "HotChocolate.Types": "16.6.4" } }, + "Macross.Json.Extensions": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "AkNshs6dopj8FXsmkkJxvLivN2SyDJQDbjcds5lo9+Y6L4zpcoXdmzXQ3VVN+AIWQr0CTD5A7vkuHGAr2aypZg==" + }, "Microsoft.Extensions.Caching.Memory": { "type": "CentralTransitive", "requested": "[10.0.11, )", @@ -1071,6 +1086,12 @@ "Microsoft.Extensions.Options": "10.0.11" } }, + "System.CommandLine": { + "type": "CentralTransitive", + "requested": "[2.0.0-beta4.22272.1, )", + "resolved": "2.0.0-beta4.22272.1", + "contentHash": "1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==" + }, "Websocket.Client": { "type": "CentralTransitive", "requested": "[5.3.0, )", From 3d1dec24a14b8baecd81d9abd8f5fe89950183ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 8 Sep 2026 07:06:48 +0200 Subject: [PATCH 2/5] Compare normalized content in the test client drift check The -Check run reported drift on Windows for clients that had not drifted. It used git status, which compares the bytes on disk: the generator always writes LF (Program.cs), while .gitattributes "text=auto" gives a CRLF working tree, so every regenerated file came back modified. git diff was already being run for the message and correctly reported nothing, which is what made the failure look contradictory. Drive the check off git diff instead, which applies the same normalization git applies on checkin. git diff only compares tracked files, so pair it with git ls-files --others to keep catching a newly generated file. --- scripts/regenerate-test-clients.ps1 | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/scripts/regenerate-test-clients.ps1 b/scripts/regenerate-test-clients.ps1 index 439a38d0..98621214 100644 --- a/scripts/regenerate-test-clients.ps1 +++ b/scripts/regenerate-test-clients.ps1 @@ -125,17 +125,27 @@ try { $paths = $clients | ForEach-Object { $_.Output } - # git status refreshes the index first, so files rewritten with identical - # content are not reported as changed just because their mtime moved. - $drifted = @(git status --porcelain -- @paths) - $diff = git diff --stat -- @paths + # Compare normalized content rather than the bytes on disk. The generator always + # writes LF, while .gitattributes "text=auto" gives a CRLF working tree on Windows, + # so git status reports every regenerated file as modified even when its content is + # unchanged. git diff applies the same normalization git would apply on checkin, so + # it reports only real drift. + $diff = @(git diff --stat -- @paths) + + # git diff only compares tracked files, so a newly generated file needs its own check. + $added = @(git ls-files --others --exclude-standard -- @paths) git checkout -- @paths - if ($drifted.Count -gt 0) { + if ($diff.Count -gt 0 -or $added.Count -gt 0) { Write-Host '' Write-Host 'The checked-in test clients do not match the generator output:' -ForegroundColor Red - Write-Host ($diff -join [Environment]::NewLine) + if ($diff.Count -gt 0) { + Write-Host ($diff -join [Environment]::NewLine) + } + foreach ($file in $added) { + Write-Host "new file: $file" + } Write-Host '' Write-Host 'Run ./scripts/regenerate-test-clients.ps1 and commit the result.' -ForegroundColor Red exit 1 From 4ce3f2e1c8d16b8447d45b76135d1d2eeae2d368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 8 Sep 2026 07:12:25 +0200 Subject: [PATCH 3/5] Pick up a linq2graphql.json in the current directory The settings file was only read when --config named it, so the common case - a repository with its generator settings checked in next to the generated output - still needed the flag on every invocation. Look for linq2graphql.json in the current directory when --config is absent, so a bare Linq2GraphQL works there. An explicit --config still wins, and still fails when it names a file that does not exist; only the discovered file is allowed to be missing. GeneratorConfig.Load already announces the path it read, so a picked-up file is visible in the output rather than silent. Note that scalarMappings is the one setting with no command line equivalent, so it cannot be overridden back off once a discovered file supplies it. --- README.md | 9 +++ src/Linq2GraphQL.Generator/GeneratorConfig.cs | 22 +++++ src/Linq2GraphQL.Generator/Program.cs | 5 +- test/Linq2GraphQL.Tests/ScalarMappingTests.cs | 81 +++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0087269b..57ab5793 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,15 @@ scripts and gives you somewhere to check the settings in: Linq2GraphQL --config linq2graphql.json +If the file is called `linq2graphql.json` and sits in the current directory it is picked up on its +own, so a repository with one in it just needs: + + Linq2GraphQL + +The generator prints the path of the file it read. An explicit `--config` always wins over the +discovered one, and pointing `--config` at a file that does not exist is an error - only the default +file is allowed to be absent. + ```json { "endpoint": "https://spacex-production.up.railway.app/", diff --git a/src/Linq2GraphQL.Generator/GeneratorConfig.cs b/src/Linq2GraphQL.Generator/GeneratorConfig.cs index ac75fc60..bf88c022 100644 --- a/src/Linq2GraphQL.Generator/GeneratorConfig.cs +++ b/src/Linq2GraphQL.Generator/GeneratorConfig.cs @@ -41,6 +41,28 @@ public class GeneratorConfig /// public Dictionary ScalarMappings { get; set; } + /// + /// The file name looked for in the current directory when no --config is passed. + /// + public const string DefaultFileName = "linq2graphql.json"; + + /// + /// The explicit --config file when one is given, otherwise a in + /// the working directory if there is one, otherwise an empty config. An explicit path that does + /// not exist is an error; a missing default file is not. + /// + public static GeneratorConfig Resolve(string configPath, string workingDirectory = null) + { + if (!string.IsNullOrWhiteSpace(configPath)) + { + return Load(configPath); + } + + var discovered = Path.Combine(workingDirectory ?? Environment.CurrentDirectory, DefaultFileName); + + return File.Exists(discovered) ? Load(discovered) : new GeneratorConfig(); + } + public static GeneratorConfig Load(string path) { var fullPath = Path.GetFullPath(path, Environment.CurrentDirectory); diff --git a/src/Linq2GraphQL.Generator/Program.cs b/src/Linq2GraphQL.Generator/Program.cs index cc1bfcd4..b5efec1b 100644 --- a/src/Linq2GraphQL.Generator/Program.cs +++ b/src/Linq2GraphQL.Generator/Program.cs @@ -14,7 +14,8 @@ private static async Task Main(string[] args) }; var configFile = new Option(new[] { "--config", "-cf" }, - "Json settings file. Every setting in it is optional and is overridden by an explicitly passed option"); + $"Json settings file, defaults to {GeneratorConfig.DefaultFileName} in the current directory. Every " + + "setting in it is optional and is overridden by an explicitly passed option"); var outputFolder = new Option(new[] { "--output", "-o" }, "Output folder, relative to current location"); var namespaceName = new Option(new[] { "--namespace", "-n" }, "Namespace of generated classes"); var clientName = new Option(new[] { "--client", "-c" }, "Name of the generated client"); @@ -45,7 +46,7 @@ private static async Task Main(string[] args) try { var configPath = result.GetValueForOption(configFile); - var config = configPath == null ? new GeneratorConfig() : GeneratorConfig.Load(configPath); + var config = GeneratorConfig.Resolve(configPath); // Fail before touching the network if a mapping target is bad. var scalarMappings = Helpers.ResolveScalarMappings(config.ScalarMappings); diff --git a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs index c394a23a..4f3119a2 100644 --- a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs +++ b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs @@ -207,4 +207,85 @@ public void ConfigFile_InvalidJson_FailsWithAHelpfulMessage() File.Delete(path); } } + + private static string NewTempDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), $"linq2graphql-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + return directory; + } + + [Fact] + public void Resolve_PicksUpTheDefaultFileInTheWorkingDirectory() + { + var directory = NewTempDirectory(); + try + { + File.WriteAllText(Path.Combine(directory, GeneratorConfig.DefaultFileName), + """{ "client": "FromDefaultFile" }"""); + + GeneratorConfig.Resolve(null, directory).Client.ShouldBe("FromDefaultFile"); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public void Resolve_WithoutAnyFile_ReturnsAnEmptyConfig() + { + var directory = NewTempDirectory(); + try + { + var config = GeneratorConfig.Resolve(null, directory); + + config.Client.ShouldBeNull(); + config.Endpoint.ShouldBeNull(); + config.ScalarMappings.ShouldBeNull(); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public void Resolve_ExplicitPathWinsOverTheDefaultFile() + { + var directory = NewTempDirectory(); + try + { + File.WriteAllText(Path.Combine(directory, GeneratorConfig.DefaultFileName), + """{ "client": "FromDefaultFile" }"""); + + var explicitPath = Path.Combine(directory, "other.json"); + File.WriteAllText(explicitPath, """{ "client": "FromExplicitFile" }"""); + + GeneratorConfig.Resolve(explicitPath, directory).Client.ShouldBe("FromExplicitFile"); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public void Resolve_ExplicitPathThatDoesNotExist_StillFails() + { + var directory = NewTempDirectory(); + try + { + // The default file being present must not paper over a bad --config. + File.WriteAllText(Path.Combine(directory, GeneratorConfig.DefaultFileName), + """{ "client": "FromDefaultFile" }"""); + + Should.Throw( + () => GeneratorConfig.Resolve(Path.Combine(directory, "missing.json"), directory)); + } + finally + { + Directory.Delete(directory, true); + } + } } From 5d87c2a9d81dbdae92ae471dcf10f33fe7e6ef12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 8 Sep 2026 07:17:17 +0200 Subject: [PATCH 4/5] Document the settings file on the docs site README.md covered the config file and scalarMappings, but the published docs at linq2graphql.com are generated from Pages/Index.razor and still described only the command line. Mirror the README section there. The option list on that page had also fallen behind: -d/--deprecated was never added when it shipped. --- docs/Linq2GraphQL.Docs/Pages/Index.razor | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/Linq2GraphQL.Docs/Pages/Index.razor b/docs/Linq2GraphQL.Docs/Pages/Index.razor index 91682974..caf60a09 100644 --- a/docs/Linq2GraphQL.Docs/Pages/Index.razor +++ b/docs/Linq2GraphQL.Docs/Pages/Index.razor @@ -56,12 +56,87 @@ Options: -nu --nullabel Nullable client [default: false] -es --enum-strategy If AddUnknownOption all enums will have an additional Unknown option -s, --subscriptions Include subscriptions (Exprimental) + -d, --deprecated Include Deprecated as Obsolete + -cf --config Json settings file, defaults to linq2graphql.json in the current directory

As an example:

Linq2GraphQL https://spacex-production.up.railway.app/ -c="SpaceXClient" -n="SpaceX" -o="Generated"
 

Would generate a client from url https://spacex-production.up.railway.app/ with the name SpaceXClient in the namespace SpaceX to folder Generated

+

Configuration file

+

+ Every option above can also be set in a json file, which keeps a long command line out of your + build scripts and gives you somewhere to check the settings in: +

+
Linq2GraphQL --config linq2graphql.json
+
+

+ If the file is called linq2graphql.json and sits in the current directory it is picked + up on its own, so a repository with one in it just needs: +

+
Linq2GraphQL
+
+

+ The generator prints the path of the file it read. An explicit --config always wins over + the discovered one, and pointing --config at a file that does not exist is an error - + only the default file is allowed to be absent. +

+
{
+  "endpoint": "https://spacex-production.up.railway.app/",
+  "client": "SpaceXClient",
+  "namespace": "SpaceX",
+  "output": "Generated",
+  "nullable": false,
+  "subscriptions": false,
+  "scalarMappings": {
+    "DateTime": "System.DateTime",
+    "BigInt": "long",
+    "Json": null
+  }
+}
+
+

+ Everything in the file is optional, and an option you pass explicitly on the command line always + wins over the file - so you can keep the shared settings in the file and override one of them for + a single run: +

+
Linq2GraphQL --config linq2graphql.json -o="SomewhereElse"
+
+

Mapping scalars to your own types

+

+ By default the generator maps the well known GraphQL scalars to CLR types (Int to + int, DateTime to DateTimeOffset, and so on) and generates a + CustomScalar class for every scalar it does not recognise. scalarMappings lets + you override both halves of that: +

+
    +
  • + Map a scalar to a simple type and it is emitted as that type - no + CustomScalar class is generated for it. "BigInt": "long" + gives you public long? Id { get; set; }. +
  • +
  • + Change an existing mapping the same way. "DateTime": + "System.DateTime" emits DateTime instead of the default + DateTimeOffset. +
  • +
  • + Map a scalar to null to opt it out of the built-in mapping, so a + CustomScalar class is generated for it instead and you control the conversion + yourself. +
  • +
+

+ Scalar names are matched case insensitively. The target must be one of the supported simple types + - bool, byte, sbyte, char, short, ushort, + int, uint, long, ulong, float, double, + decimal, string, Guid, Uri, DateTime, + DateTimeOffset, DateOnly, TimeOnly, TimeSpan - written either + as the C# keyword (long), the type name (Int64) or its full name + (System.Int64). Anything else fails the run with an error rather than generating a + client that does not compile. +

Add Nuget

Add the Nuget Package Linq2GraphQL.Client

dotnet add package Linq2GraphQL.Client --prerelease

From 4a8a66f6547c723d76383b947b6acd600e0f61ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= 
Date: Tue, 8 Sep 2026 07:24:57 +0200
Subject: [PATCH 5/5] Document every setting and reject the ones we do not know

The settings file was documented by example only. Three settings - token,
enumStrategy and deprecated - appeared in no example anywhere, and the mapping
from a flag like --enum-strategy onto the json key enumStrategy was never
stated, so the only way to find them was to guess. Add a table of every setting
with its type, default and command line equivalent to both the README and the
docs site, and put the two boolean ones in the example.

Unknown settings are now an error. System.Text.Json drops unmapped members
silently, so the singular "scalarMapping" - the typo most worth catching -
would produce a client with none of the mappings applied and no indication
why. The known set is derived from the properties by reflection so it cannot
fall out of sync, and only top level keys are checked: the keys inside
scalarMappings are scalar names we cannot know up front.
---
 README.md                                     | 25 ++++++
 docs/Linq2GraphQL.Docs/Pages/Index.razor      | 37 ++++++++
 src/Linq2GraphQL.Generator/GeneratorConfig.cs | 48 ++++++++++-
 test/Linq2GraphQL.Tests/ScalarMappingTests.cs | 86 +++++++++++++++++++
 4 files changed, 195 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 57ab5793..77d76fc7 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,8 @@ file is allowed to be absent.
   "output": "Generated",
   "nullable": false,
   "subscriptions": false,
+  "deprecated": false,
+  "enumStrategy": "FailIfMissing",
   "scalarMappings": {
     "DateTime": "System.DateTime",
     "BigInt": "long",
@@ -111,6 +113,29 @@ file is allowed to be absent.
 }
 ```
 
+| Setting          | Type   | Default                  | Command line          |
+|------------------|--------|--------------------------|-----------------------|
+| `endpoint`       | string | *required*               | `` argument |
+| `output`         | string | `Linq2GraphQL_Generated` | `-o`, `--output`      |
+| `namespace`      | string | `YourNamespace`          | `-n`, `--namespace`   |
+| `client`         | string | `GraphQLClient`          | `-c`, `--client`      |
+| `token`          | string | none                     | `-t`, `--token`       |
+| `subscriptions`  | bool   | `false`                  | `-s`, `--subscriptions` |
+| `enumStrategy`   | string | `FailIfMissing`          | `-es`, `--enum-strategy` |
+| `nullable`       | bool   | `false`                  | `-nu`, `--nullable`   |
+| `deprecated`     | bool   | `false`                  | `-d`, `--deprecated`  |
+| `scalarMappings` | object | none                     | *no equivalent*       |
+
+`endpoint` is required only in the sense that it has to come from somewhere - the file or the
+command line argument. `enumStrategy` takes `AddUnknownOption` to give every generated enum an extra
+`Unknown` member; any other value means `FailIfMissing`. `token` is settable here for completeness,
+but a bearer token is usually better passed as `-t` than checked into a file.
+
+Setting names are matched case insensitively. A name that is not in the table above fails the run,
+so a typo like `scalarMapping` is reported rather than silently ignored. `scalarMappings` has no
+command line equivalent, so it is the one setting a discovered file supplies that you cannot
+override back off from the command line.
+
 Everything in the file is optional, and an option you pass explicitly on the command line always wins
 over the file - so you can keep the shared settings in the file and override one of them for a single
 run:
diff --git a/docs/Linq2GraphQL.Docs/Pages/Index.razor b/docs/Linq2GraphQL.Docs/Pages/Index.razor
index caf60a09..403c3a27 100644
--- a/docs/Linq2GraphQL.Docs/Pages/Index.razor
+++ b/docs/Linq2GraphQL.Docs/Pages/Index.razor
@@ -89,6 +89,8 @@ Options:
   "output": "Generated",
   "nullable": false,
   "subscriptions": false,
+  "deprecated": false,
+  "enumStrategy": "FailIfMissing",
   "scalarMappings": {
     "DateTime": "System.DateTime",
     "BigInt": "long",
@@ -96,6 +98,41 @@ Options:
   }
 }
 
+ + + + + + + + + + + + + + + + + + + + + +
SettingTypeDefaultCommand line
endpointstringrequired<endpoint> argument
outputstringLinq2GraphQL_Generated-o, --output
namespacestringYourNamespace-n, --namespace
clientstringGraphQLClient-c, --client
tokenstringnone-t, --token
subscriptionsboolfalse-s, --subscriptions
enumStrategystringFailIfMissing-es, --enum-strategy
nullableboolfalse-nu, --nullable
deprecatedboolfalse-d, --deprecated
scalarMappingsobjectnoneno equivalent
+

+ endpoint is required only in the sense that it has to come from somewhere - the file or + the command line argument. enumStrategy takes AddUnknownOption to give every + generated enum an extra Unknown member; any other value means FailIfMissing. + token is settable here for completeness, but a bearer token is usually better passed as + -t than checked into a file. +

+

+ Setting names are matched case insensitively. A name that is not in the table above fails the + run, so a typo like scalarMapping is reported rather than silently ignored. + scalarMappings has no command line equivalent, so it is the one setting a discovered + file supplies that you cannot override back off from the command line. +

Everything in the file is optional, and an option you pass explicitly on the command line always wins over the file - so you can keep the shared settings in the file and override one of them for diff --git a/src/Linq2GraphQL.Generator/GeneratorConfig.cs b/src/Linq2GraphQL.Generator/GeneratorConfig.cs index bf88c022..e7fd5d27 100644 --- a/src/Linq2GraphQL.Generator/GeneratorConfig.cs +++ b/src/Linq2GraphQL.Generator/GeneratorConfig.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; @@ -16,6 +17,17 @@ public class GeneratorConfig AllowTrailingCommas = true }; + ///

+ /// The settings this file understands, derived from the properties below so the two cannot fall + /// out of sync. Compared case insensitively, like the deserializer itself. + /// + private static readonly HashSet KnownSettings = + typeof(GeneratorConfig) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(e => e.CanWrite) + .Select(e => JsonNamingPolicy.CamelCase.ConvertName(e.Name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + public string Endpoint { get; set; } public string Output { get; set; } @@ -74,10 +86,13 @@ public static GeneratorConfig Load(string path) Console.WriteLine($"Reading configuration from {fullPath}"); + var json = File.ReadAllText(fullPath); + GeneratorConfig config; try { - config = JsonSerializer.Deserialize(File.ReadAllText(fullPath), SerializerOptions); + ValidateKnownSettings(json, fullPath); + config = JsonSerializer.Deserialize(json, SerializerOptions); } catch (JsonException ex) { @@ -86,4 +101,35 @@ public static GeneratorConfig Load(string path) return config ?? new GeneratorConfig(); } + + /// + /// Rejects a setting we do not understand. System.Text.Json drops unmapped members silently, so + /// without this a typo like "scalarMapping" would be ignored and the run would quietly produce + /// the wrong client. Only top level settings are checked - the keys inside + /// are scalar names, which we cannot know up front. + /// + private static void ValidateKnownSettings(string json, string fullPath) + { + using var document = JsonDocument.Parse(json, + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new GeneratorConfigurationException($"Config file {fullPath} must contain a json object."); + } + + var unknown = document.RootElement.EnumerateObject() + .Select(e => e.Name) + .Where(e => !KnownSettings.Contains(e)) + .ToList(); + + if (unknown.Count == 0) + { + return; + } + + throw new GeneratorConfigurationException( + $"Unknown setting{(unknown.Count == 1 ? "" : "s")} in {fullPath}: {string.Join(", ", unknown)}. " + + $"Supported settings are: {string.Join(", ", KnownSettings.OrderBy(e => e, StringComparer.Ordinal))}."); + } } diff --git a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs index 4f3119a2..68c8f3bb 100644 --- a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs +++ b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs @@ -288,4 +288,90 @@ public void Resolve_ExplicitPathThatDoesNotExist_StillFails() Directory.Delete(directory, true); } } + + private static GeneratorConfig LoadJson(string json) + { + var directory = NewTempDirectory(); + try + { + var path = Path.Combine(directory, GeneratorConfig.DefaultFileName); + File.WriteAllText(path, json); + return GeneratorConfig.Load(path); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public void Load_UnknownSetting_FailsInsteadOfBeingIgnored() + { + // The singular "scalarMapping" is the typo this is really guarding against: System.Text.Json + // would drop it and generate a client with none of the mappings applied. + var ex = Should.Throw( + () => LoadJson("""{ "scalarMapping": { "BigInt": "long" } }""")); + + ex.Message.ShouldContain("scalarMapping"); + ex.Message.ShouldContain("scalarMappings"); + ex.Message.ShouldContain("enumStrategy"); + } + + [Fact] + public void Load_SeveralUnknownSettings_AreAllReported() + { + var ex = Should.Throw( + () => LoadJson("""{ "nope": 1, "alsoNope": 2 }""")); + + ex.Message.ShouldContain("nope"); + ex.Message.ShouldContain("alsoNope"); + ex.Message.ShouldContain("Unknown settings"); + } + + [Fact] + public void Load_AcceptsEverySettingTheCommandLineHas() + { + var config = LoadJson(""" + { + "endpoint": "https://example.com/graphql", + "output": "Generated", + "namespace": "My.Ns", + "client": "MyClient", + "token": "secret", + "subscriptions": true, + "enumStrategy": "AddUnknownOption", + "nullable": true, + "deprecated": true, + "scalarMappings": { "BigInt": "long" } + } + """); + + config.Endpoint.ShouldBe("https://example.com/graphql"); + config.Output.ShouldBe("Generated"); + config.Namespace.ShouldBe("My.Ns"); + config.Client.ShouldBe("MyClient"); + config.Token.ShouldBe("secret"); + config.Subscriptions.ShouldBe(true); + config.EnumStrategy.ShouldBe("AddUnknownOption"); + config.Nullable.ShouldBe(true); + config.Deprecated.ShouldBe(true); + config.ScalarMappings!["BigInt"].ShouldBe("long"); + } + + [Fact] + public void Load_SettingNamesAreCaseInsensitive() + { + var config = LoadJson("""{ "Endpoint": "https://example.com/graphql", "NULLABLE": true }"""); + + config.Endpoint.ShouldBe("https://example.com/graphql"); + config.Nullable.ShouldBe(true); + } + + [Fact] + public void Load_RootThatIsNotAnObject_FailsWithAHelpfulMessage() + { + var ex = Should.Throw(() => LoadJson("[]")); + + ex.Message.ShouldContain("must contain a json object"); + } }