diff --git a/.refitter b/.refitter new file mode 100644 index 000000000..1179f61a6 --- /dev/null +++ b/.refitter @@ -0,0 +1,88 @@ +{ + "openApiPath": "./test-anytype.json", + "openApiPaths": [], + "namespace": "TestNS", + "contractsNamespace": null, + "naming": { + "useOpenApiTitle": true, + "interfaceName": "ApiClient" + }, + "generateContracts": true, + "generateClients": true, + "generateDisposableClients": false, + "generateXmlDocCodeComments": true, + "generateStatusCodeComments": true, + "addAutoGeneratedHeader": true, + "addAcceptHeaders": true, + "addContentTypeHeaders": true, + "returnIApiResponse": false, + "returnIObservable": false, + "responseTypeOverride": {}, + "generateOperationHeaders": true, + "ignoredOperationHeaders": [], + "typeAccessibility": "Public", + "useCancellationTokens": false, + "useIsoDateFormat": false, + "additionalNamespaces": [], + "excludeNamespaces": [], + "multipleInterfaces": "Unset", + "includePathMatches": [], + "includeTags": [], + "generateDeprecatedOperations": true, + "operationNameTemplate": null, + "optionalParameters": false, + "outputFolder": "./Generated", + "contractsOutputFolder": "./test-output.cs", + "outputFilename": null, + "dependencyInjectionSettings": null, + "codeGeneratorSettings": { + "requiredPropertiesMustBeDefined": true, + "generateDataAnnotations": true, + "anyType": "object", + "dateType": "System.DateTimeOffset", + "dateTimeType": "System.DateTimeOffset", + "timeType": "System.TimeSpan", + "timeSpanType": "System.TimeSpan", + "integerType": "Int32", + "arrayType": "System.Collections.Generic.ICollection", + "dictionaryType": "System.Collections.Generic.IDictionary", + "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "dictionaryInstanceType": "System.Collections.Generic.Dictionary", + "arrayBaseType": "System.Collections.ObjectModel.Collection", + "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "propertySetterAccessModifier": "", + "jsonConverters": null, + "generateImmutableArrayProperties": false, + "generateImmutableDictionaryProperties": false, + "handleReferences": false, + "jsonSerializerSettingsTransformationMethod": null, + "generateJsonMethods": false, + "enforceFlagEnums": false, + "inlineNamedDictionaries": false, + "inlineNamedTuples": true, + "inlineNamedArrays": false, + "generateOptionalPropertiesAsNullable": false, + "generateNullableReferenceTypes": false, + "generateNativeRecords": false, + "generateDefaultValues": true, + "inlineNamedAny": false, + "excludedTypeNames": [], + "dateFormat": null, + "dateTimeFormat": null, + "inlineJsonConverters": true, + "customTemplateDirectory": null + }, + "trimUnusedSchema": false, + "keepSchemaPatterns": [], + "includeInheritanceHierarchy": false, + "operationNameGenerator": "Default", + "generateDefaultAdditionalProperties": true, + "immutableRecords": false, + "apizrSettings": null, + "useDynamicQuerystringParameters": false, + "generateMultipleFiles": false, + "usePolymorphicSerialization": false, + "generateAuthenticationHeader": false, + "collectionFormat": "Multi", + "customTemplateDirectory": null +} \ No newline at end of file diff --git a/docs/format-mappings-example.json b/docs/format-mappings-example.json new file mode 100644 index 000000000..b51e14078 --- /dev/null +++ b/docs/format-mappings-example.json @@ -0,0 +1,6 @@ +{ + "int32": "int", + "int64": "ulong", + "float": "float", + "double": "double" +} diff --git a/docs/format-mappings-schema.json b/docs/format-mappings-schema.json new file mode 100644 index 000000000..47d75bbb7 --- /dev/null +++ b/docs/format-mappings-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Refitter Format Mappings", + "description": "Custom format-to-type mappings for OpenAPI schema formats. Use this to override the default C# type mappings for specific OpenAPI formats.", + "type": "object", + "additionalProperties": { + "type": "string", + "description": "The .NET type name to use for this format" + }, + "examples": [ + { + "int32": "int", + "int64": "ulong", + "float": "float", + "double": "double", + "byte": "byte", + "binary": "byte[]", + "date": "System.DateTime", + "date-time": "System.DateTime", + "password": "string", + "uuid": "System.Guid" + } + ] +} diff --git a/src/Refitter.Core/ContractTypeSuffixApplier.cs b/src/Refitter.Core/ContractTypeSuffixApplier.cs new file mode 100644 index 000000000..7a2d8239d --- /dev/null +++ b/src/Refitter.Core/ContractTypeSuffixApplier.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; + +namespace Refitter.Core; + +internal static class ContractTypeSuffixApplier +{ + private static readonly Regex TypeDeclarationRegex = + new(@"(?:public|internal)\s+(?:partial\s+)?(?:class|record|struct|interface)\s+(\w+)", RegexOptions.Multiline | RegexOptions.Compiled); + + private static readonly Regex EnumDeclarationRegex = + new(@"(?:public|internal)\s+(?:partial\s+)?enum\s+(\w+)", RegexOptions.Multiline | RegexOptions.Compiled); + + public static string ApplySuffix(string generatedCode, string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) + return generatedCode; + + var typeNames = new HashSet(); + + // First pass: collect all type names (classes, records, structs, enums) + foreach (Match match in TypeDeclarationRegex.Matches(generatedCode)) + { + typeNames.Add(match.Groups[1].Value); + } + + foreach (Match match in EnumDeclarationRegex.Matches(generatedCode)) + { + typeNames.Add(match.Groups[1].Value); + } + + // Second pass: replace type names with suffixed versions + // Sort by length (longest first) to avoid partial replacements + var orderedTypeNames = typeNames.OrderByDescending(t => t.Length).ToList(); + var result = generatedCode; + + foreach (var typeName in orderedTypeNames) + { + var suffixedName = typeName + suffix; + + // Replace type declarations + result = Regex.Replace( + result, + $@"(?:public|internal)\s+((?:partial\s+)?(?:class|record|struct|enum))\s+\b{Regex.Escape(typeName)}\b", + m => $"{m.Groups[0].Value.Replace(typeName, suffixedName)}", + RegexOptions.Multiline); + + // Replace type references in inheritance, properties, parameters, etc. + // Match word boundaries to avoid partial replacements + result = Regex.Replace( + result, + $@"\b{Regex.Escape(typeName)}\b(?![a-zA-Z0-9_])", + suffixedName); + } + + return result; + } +} diff --git a/src/Refitter.Core/CustomCSharpTypeResolver.cs b/src/Refitter.Core/CustomCSharpTypeResolver.cs new file mode 100644 index 000000000..ea7f20ab9 --- /dev/null +++ b/src/Refitter.Core/CustomCSharpTypeResolver.cs @@ -0,0 +1,38 @@ +using NJsonSchema; +using NJsonSchema.CodeGeneration; +using NJsonSchema.CodeGeneration.CSharp; + +namespace Refitter.Core; + +internal class CustomCSharpTypeResolver : CSharpTypeResolver +{ + private readonly Dictionary? formatMappings; + + public CustomCSharpTypeResolver( + CSharpGeneratorSettings settings, + Dictionary? formatMappings) + : base(settings) + { + this.formatMappings = formatMappings; + } + + public override string Resolve( + JsonSchema schema, + bool isNullable, + string? typeNameHint) + { + // Check if this schema has a format with a custom mapping + if (formatMappings != null && + !string.IsNullOrEmpty(schema.Format) && + formatMappings.TryGetValue(schema.Format, out var mappedType)) + { + // Return the custom mapped type with nullability + return isNullable && !mappedType.EndsWith("?") && !mappedType.Contains("<") + ? $"{mappedType}?" + : mappedType; + } + + // Fall back to default NSwag type resolution + return base.Resolve(schema, isNullable, typeNameHint); + } +} diff --git a/src/Refitter.Core/JsonSerializerContextGenerator.cs b/src/Refitter.Core/JsonSerializerContextGenerator.cs new file mode 100644 index 000000000..3052db2a2 --- /dev/null +++ b/src/Refitter.Core/JsonSerializerContextGenerator.cs @@ -0,0 +1,75 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Refitter.Core; + +/// +/// Generates JsonSerializerContext for AOT compilation support +/// +internal static class JsonSerializerContextGenerator +{ + /// + /// Generates a JsonSerializerContext class with all DTO types registered for source generation + /// + /// The generated contracts code containing the DTO types + /// The generator settings + /// The generated JsonSerializerContext code + public static string Generate(string contracts, RefitGeneratorSettings settings) + { + var typeNames = ExtractTypeNames(contracts); + if (typeNames.Count == 0) + return string.Empty; + + var contextName = $"{settings.Naming.InterfaceName}SerializerContext"; + var sb = new StringBuilder(); + + // Add all JsonSerializable attributes + foreach (var typeName in typeNames.OrderBy(t => t)) + { + sb.AppendLine($"[JsonSerializable(typeof({typeName}))]"); + } + + // Add context class + sb.AppendLine($"internal partial class {contextName} : JsonSerializerContext"); + sb.AppendLine("{"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Extracts type names (classes, records, enums) from generated contracts + /// + private static HashSet ExtractTypeNames(string contracts) + { + var typeNames = new HashSet(); + + // Match class declarations: public class TypeName, internal class TypeName + var classPattern = new Regex( + @"^\s*(?:public|internal)\s+(?:partial\s+)?(?:class|record)\s+([A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Multiline); + + foreach (Match match in classPattern.Matches(contracts)) + { + if (match.Success && match.Groups.Count > 1) + { + typeNames.Add(match.Groups[1].Value); + } + } + + // Match enum declarations: public enum TypeName, internal enum TypeName + var enumPattern = new Regex( + @"^\s*(?:public|internal)\s+enum\s+([A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Multiline); + + foreach (Match match in enumPattern.Matches(contracts)) + { + if (match.Success && match.Groups.Count > 1) + { + typeNames.Add(match.Groups[1].Value); + } + } + + return typeNames; + } +} diff --git a/src/Refitter.Core/ParameterExtractor.cs b/src/Refitter.Core/ParameterExtractor.cs index 9bf4a60db..1f6df7a60 100644 --- a/src/Refitter.Core/ParameterExtractor.cs +++ b/src/Refitter.Core/ParameterExtractor.cs @@ -27,7 +27,7 @@ public static IEnumerable GetParameters( var bodyParameters = operationModel.Parameters .Where(p => p.Kind == OpenApiParameterKind.Body && !p.IsBinaryBodyParameter) .Select(p => - $"{JoinAttributes("Body", GetAliasAsAttribute(p))}{GetParameterType(p, settings)} {p.VariableName}") + $"{JoinAttributes(GetBodyAttribute(p, settings), GetAliasAsAttribute(p))}{GetParameterType(p, settings)} {p.VariableName}") .ToList(); var headerParameters = new List(); @@ -74,6 +74,43 @@ public static IEnumerable GetParameters( $"{JoinAttributes(GetAliasAsAttribute(p))}{GetParameterType(p, settings)} {p.VariableName}") .ToList(); + // Manually extract non-binary properties from multipart/form-data in OpenAPI 3.x + // NSwag doesn't populate these in operationModel.Parameters + if (operation.RequestBody?.Content?.TryGetValue("multipart/form-data", out var multipartContent) == true) + { + var schema = multipartContent.Schema; + if (schema?.Properties != null) + { + foreach (var property in schema.Properties) + { + var propertySchema = property.Value; + + // Skip binary fields (files) as they're already handled as StreamPart + var isBinary = propertySchema.Type == JsonObjectType.String && + propertySchema.Format == "binary"; + + if (!isBinary) + { + // Generate proper C# type for the property + var propertyType = GetCSharpType(propertySchema, settings); + var variableName = ConvertToVariableName(property.Key); + + // Add AliasAs attribute if property name differs from variable name + var aliasAttribute = property.Key != variableName + ? $"AliasAs(""{property.Key}"")" + : string.Empty; + + var parameter = $"{JoinAttributes(aliasAttribute)}{propertyType} {variableName}"; + + // Only add if not already present (avoid duplicates) + if (!formParameters.Contains(parameter)) + { + formParameters.Add(parameter); + } + } + } + } + } var binaryBodyParameters = operationModel.Parameters .Where(p => p.Kind == OpenApiParameterKind.Body && p.IsBinaryBodyParameter) .Select(p => @@ -254,6 +291,21 @@ private static bool IsNumericType(string type) or "ulong" or "UInt64" or "ushort" or "UInt16"; } + private static string GetBodyAttribute(CSharpParameterModel parameter, RefitGeneratorSettings settings) + { + var anyType = settings.CodeGeneratorSettings?.AnyType ?? "object"; + var parameterType = WellKnownNamespaces.TrimImportedNamespaces(FindSupportedType(parameter.Type)); + + // Check if the parameter type matches AnyType (e.g., "object" or custom type like "System.Text.Json.JsonElement") + if (parameterType.Equals(anyType, StringComparison.OrdinalIgnoreCase) || + parameterType.Contains("JsonElement", StringComparison.OrdinalIgnoreCase)) + { + return "Body(BodySerializationMethod.Serialized)"; + } + + return "Body"; + } + private static string GetQueryAttribute(CSharpParameterModel parameter, RefitGeneratorSettings settings) { return (parameter, settings) switch @@ -471,4 +523,70 @@ private static void AppendXmlDocComment(string description, StringBuilder codeBu """); codeBuilder.AppendLine(); } + + private static string GetCSharpType(JsonSchema propertySchema, RefitGeneratorSettings settings) + { + var type = propertySchema.Type switch + { + JsonObjectType.String => "string", + JsonObjectType.Integer => GetIntegerTypeName(propertySchema, settings), + JsonObjectType.Number => "double", + JsonObjectType.Boolean => "bool", + JsonObjectType.Array => GetArrayType(propertySchema, settings), + JsonObjectType.Object => "object", + _ => "object" + }; + + // Add nullable modifier if needed + if (settings.OptionalParameters && propertySchema.IsNullable(SchemaType.OpenApi3)) + { + type += "?"; + } + + return type; + } + + private static string GetIntegerTypeName(JsonSchema schema, RefitGeneratorSettings settings) + { + // Check the format first + if (schema.Format == "int64") + return "long"; + if (schema.Format == "int32") + return "int"; + + // Fall back to settings + var integerType = settings.CodeGeneratorSettings?.IntegerType ?? IntegerType.Int32; + return integerType == IntegerType.Int64 ? "long" : "int"; + } + + private static string GetArrayType(JsonSchema arraySchema, RefitGeneratorSettings settings) + { + if (arraySchema.Item != null) + { + var itemType = GetCSharpType(arraySchema.Item, settings); + return $"{itemType}[]"; + } + return "object[]"; + } + + private static string ConvertToVariableName(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + return "value"; + + // Convert first character to lowercase for camelCase + var variableName = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + + // Replace invalid characters with underscore + var safeVariableName = new StringBuilder(variableName.Length); + foreach (var c in variableName) + { + if (char.IsLetterOrDigit(c) || c == '_') + safeVariableName.Append(c); + else + safeVariableName.Append('_'); + } + + return safeVariableName.ToString(); + } } diff --git a/src/Refitter.Core/Settings/RefitGeneratorSettings.cs b/src/Refitter.Core/Settings/RefitGeneratorSettings.cs index ea978f893..e78ff7d68 100644 --- a/src/Refitter.Core/Settings/RefitGeneratorSettings.cs +++ b/src/Refitter.Core/Settings/RefitGeneratorSettings.cs @@ -401,4 +401,10 @@ payloads with (yet) unknown types are offered by newer versions of an API /// [Description("Custom directory with NSwag fluid templates for code generation. Default is null which uses the default NSwag templates. See https://github.com/RicoSuter/NSwag/wiki/Templates")] public string? CustomTemplateDirectory { get; set; } + + /// + /// Gets or sets a value indicating whether to generate JsonSerializerContext for AOT compilation support. + /// + [Description("Generate JsonSerializerContext for AOT compilation support")] + public bool GenerateJsonSerializerContext { get; set; } } diff --git a/src/Refitter.Tests/Examples/AnyTypeBodySerializationTests.cs b/src/Refitter.Tests/Examples/AnyTypeBodySerializationTests.cs new file mode 100644 index 000000000..f3edd51ee --- /dev/null +++ b/src/Refitter.Tests/Examples/AnyTypeBodySerializationTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Refitter.Core; +using Refitter.Tests.Build; +using Xunit; + +namespace Refitter.Tests.Examples; + +public class AnyTypeBodySerializationTests +{ + private const string OpenApiSpec = @" +{ + ""openapi"": ""3.0.0"", + ""info"": { + ""title"": ""Example API"", + ""version"": ""1.0.0"" + }, + ""paths"": { + ""/some/endpoint"": { + ""post"": { + ""summary"": ""Create message with untyped payload"", + ""operationId"": ""createMessage"", + ""requestBody"": { + ""required"": true, + ""content"": { + ""application/json"": { + ""schema"": { + ""type"": ""object"", + ""additionalProperties"": true + } + } + } + }, + ""responses"": { + ""200"": { + ""description"": ""Success"", + ""content"": { + ""application/json"": { + ""schema"": { + ""type"": ""object"", + ""additionalProperties"": true + } + } + } + } + } + } + } + } +} +"; + + [Fact] + public async Task Generated_Code_Contains_BodySerializationMethod_For_Object_Parameter() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().Contain("[Body(BodySerializationMethod.Serialized)] object body"); + } + + [Fact] + public async Task Can_Build_Generated_Code() + { + string generatedCode = await GenerateCode(); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + private static async Task GenerateCode() + { + var settings = new RefitGeneratorSettings + { + OpenApiPath = "petstore.json" + }; + + return await RefitGenerator.Generate( + new OpenApiSpecification(OpenApiSpec), + settings + ); + } +} diff --git a/src/Refitter.Tests/Examples/ContractTypeSuffixTests.cs b/src/Refitter.Tests/Examples/ContractTypeSuffixTests.cs new file mode 100644 index 000000000..15b2c8159 --- /dev/null +++ b/src/Refitter.Tests/Examples/ContractTypeSuffixTests.cs @@ -0,0 +1,245 @@ +using FluentAssertions; +using Refitter.Core; +using Refitter.Tests.Build; +using Refitter.Tests.TestUtilities; +using TUnit.Core; + +namespace Refitter.Tests.Examples; + +public class ContractTypeSuffixTests +{ + private const string OpenApiSpec = @" +{ + ""openapi"": ""3.0.1"", + ""info"": { + ""title"": ""Pet Store API"", + ""version"": ""v1"" + }, + ""paths"": { + ""/api/pets"": { + ""get"": { + ""operationId"": ""GetPets"", + ""responses"": { + ""200"": { + ""description"": ""Success"", + ""content"": { + ""application/json"": { + ""schema"": { + ""type"": ""array"", + ""items"": { + ""$ref"": ""#/components/schemas/Pet"" + } + } + } + } + } + } + }, + ""post"": { + ""operationId"": ""CreatePet"", + ""requestBody"": { + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/PetRequest"" + } + } + } + }, + ""responses"": { + ""201"": { + ""description"": ""Created"", + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/Pet"" + } + } + } + } + } + } + }, + ""/api/owners/{ownerId}"": { + ""get"": { + ""operationId"": ""GetOwner"", + ""parameters"": [ + { + ""name"": ""ownerId"", + ""in"": ""path"", + ""required"": true, + ""schema"": { + ""type"": ""integer"", + ""format"": ""int64"" + } + } + ], + ""responses"": { + ""200"": { + ""description"": ""Success"", + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/Owner"" + } + } + } + } + } + } + } + }, + ""components"": { + ""schemas"": { + ""Pet"": { + ""type"": ""object"", + ""properties"": { + ""id"": { + ""type"": ""integer"", + ""format"": ""int64"" + }, + ""name"": { + ""type"": ""string"" + }, + ""status"": { + ""$ref"": ""#/components/schemas/PetStatus"" + }, + ""owner"": { + ""$ref"": ""#/components/schemas/Owner"" + } + } + }, + ""PetRequest"": { + ""type"": ""object"", + ""properties"": { + ""name"": { + ""type"": ""string"" + }, + ""ownerId"": { + ""type"": ""integer"", + ""format"": ""int64"" + } + } + }, + ""Owner"": { + ""type"": ""object"", + ""properties"": { + ""id"": { + ""type"": ""integer"", + ""format"": ""int64"" + }, + ""name"": { + ""type"": ""string"" + } + } + }, + ""PetStatus"": { + ""type"": ""string"", + ""enum"": [""Available"", ""Pending"", ""Sold""] + } + } + } +} +"; + + [Test] + public async Task Can_Generate_Code_With_Suffix() + { + string generatedCode = await GenerateCode("Dto"); + generatedCode.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task Generated_Code_Contains_Dto_Suffix_For_Classes() + { + string generatedCode = await GenerateCode("Dto"); + generatedCode.Should().Contain("public partial class PetDto"); + generatedCode.Should().Contain("public partial class PetRequestDto"); + generatedCode.Should().Contain("public partial class OwnerDto"); + } + + [Test] + public async Task Generated_Code_Contains_Dto_Suffix_For_Enums() + { + string generatedCode = await GenerateCode("Dto"); + generatedCode.Should().Contain("public enum PetStatusDto"); + } + + [Test] + public async Task Generated_Code_Uses_Suffixed_Types_In_Properties() + { + string generatedCode = await GenerateCode("Dto"); + generatedCode.Should().Contain("public PetStatusDto"); + generatedCode.Should().Contain("public OwnerDto"); + } + + [Test] + public async Task Generated_Code_Uses_Suffixed_Types_In_Method_Signatures() + { + string generatedCode = await GenerateCode("Dto"); + generatedCode.Should().Contain("Task>"); + generatedCode.Should().Contain("Task"); + generatedCode.Should().Contain("Task"); + generatedCode.Should().Contain("[Body] PetRequestDto"); + } + + [Test] + public async Task Can_Generate_Code_With_Contract_Suffix() + { + string generatedCode = await GenerateCode("Contract"); + generatedCode.Should().Contain("public partial class PetContract"); + generatedCode.Should().Contain("public partial class OwnerContract"); + generatedCode.Should().Contain("public enum PetStatusContract"); + } + + [Test] + public async Task Can_Generate_Code_With_Model_Suffix() + { + string generatedCode = await GenerateCode("Model"); + generatedCode.Should().Contain("public partial class PetModel"); + generatedCode.Should().Contain("public partial class OwnerModel"); + generatedCode.Should().Contain("public enum PetStatusModel"); + } + + [Test] + public async Task Can_Build_Generated_Code_With_Suffix() + { + string generatedCode = await GenerateCode("Dto"); + BuildHelper + .BuildCSharp(generatedCode) + .Should() + .BeTrue(); + } + + [Test] + public async Task Does_Not_Apply_Suffix_When_Not_Specified() + { + string generatedCode = await GenerateCode(null); + generatedCode.Should().Contain("public partial class Pet"); + generatedCode.Should().NotContain("public partial class PetDto"); + generatedCode.Should().Contain("public partial class Owner"); + generatedCode.Should().NotContain("public partial class OwnerDto"); + } + + [Test] + public async Task Does_Not_Apply_Suffix_When_Empty() + { + string generatedCode = await GenerateCode(string.Empty); + generatedCode.Should().Contain("public partial class Pet"); + generatedCode.Should().NotContain("public partial class PetDto"); + } + + private static async Task GenerateCode(string? contractTypeSuffix) + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + ContractTypeSuffix = contractTypeSuffix + }; + + var sut = await RefitGenerator.CreateAsync(settings); + var generatedCode = sut.Generate(); + return generatedCode; + } +} diff --git a/src/Refitter.Tests/Examples/MultipleInterfacesByTagMethodNamingTests.cs b/src/Refitter.Tests/Examples/MultipleInterfacesByTagMethodNamingTests.cs index 3043c2698..9710076ed 100644 --- a/src/Refitter.Tests/Examples/MultipleInterfacesByTagMethodNamingTests.cs +++ b/src/Refitter.Tests/Examples/MultipleInterfacesByTagMethodNamingTests.cs @@ -166,14 +166,14 @@ public async Task Generates_Separate_Interfaces_For_Each_Tag() public async Task Users_Interface_Should_Not_Have_Numbered_Method_Names() { string generatedCode = await GenerateCode(); - + // Users interface should have method names WITHOUT numeric suffixes generatedCode.Should().Contain("Task GetAllUsers("); generatedCode.Should().Contain("Task CreateUser("); generatedCode.Should().Contain("Task GetUserById("); generatedCode.Should().Contain("Task UpdateUser("); generatedCode.Should().Contain("Task DeleteUser("); - + // Should NOT contain numbered versions in Users interface generatedCode.Should().NotContain("GetAllUsers2"); generatedCode.Should().NotContain("CreateUser2"); @@ -184,14 +184,14 @@ public async Task Users_Interface_Should_Not_Have_Numbered_Method_Names() public async Task Products_Interface_Should_Not_Have_Numbered_Method_Names() { string generatedCode = await GenerateCode(); - + // Products interface should have method names WITHOUT numeric suffixes generatedCode.Should().Contain("Task GetAllProducts("); generatedCode.Should().Contain("Task CreateProduct("); generatedCode.Should().Contain("Task GetProductById("); generatedCode.Should().Contain("Task UpdateProduct("); generatedCode.Should().Contain("Task DeleteProduct("); - + // Should NOT contain numbered versions generatedCode.Should().NotContain("GetAllProducts2"); generatedCode.Should().NotContain("CreateProduct2"); @@ -201,12 +201,12 @@ public async Task Products_Interface_Should_Not_Have_Numbered_Method_Names() public async Task Orders_Interface_Should_Not_Have_Numbered_Method_Names() { string generatedCode = await GenerateCode(); - + // Orders interface should have method names WITHOUT numeric suffixes generatedCode.Should().Contain("Task GetAllOrders("); generatedCode.Should().Contain("Task CreateOrder("); generatedCode.Should().Contain("Task GetOrderById("); - + // Should NOT contain numbered versions generatedCode.Should().NotContain("GetAllOrders2"); generatedCode.Should().NotContain("CreateOrder2"); @@ -216,30 +216,30 @@ public async Task Orders_Interface_Should_Not_Have_Numbered_Method_Names() public async Task Method_Names_Should_Be_Identical_Across_Different_Interfaces() { string generatedCode = await GenerateCode(); - + // All interfaces have similar patterns (GetAll*, Create*, Get*ById) // These should NOT have global numbering - + // Extract just the method signatures to analyze var lines = generatedCode.Split('\n'); - + // Look for method declarations with Task var methodDeclarations = lines .Where(l => l.Contains("Task") && l.Contains("(")) .Select(l => l.Trim()) .ToList(); - + // Check that we have the expected method names without numeric suffixes methodDeclarations.Should().Contain(m => m.Contains("GetAllUsers(")); methodDeclarations.Should().Contain(m => m.Contains("GetAllProducts(")); methodDeclarations.Should().Contain(m => m.Contains("GetAllOrders(")); - + // Verify NO methods have numeric suffixes like GetAll*2, Create*2, etc. - methodDeclarations.Should().NotContain(m => + methodDeclarations.Should().NotContain(m => m.Contains("GetAll") && (m.Contains("2(") || m.Contains("3("))); - methodDeclarations.Should().NotContain(m => + methodDeclarations.Should().NotContain(m => m.Contains("Create") && (m.Contains("2(") || m.Contains("3("))); - methodDeclarations.Should().NotContain(m => + methodDeclarations.Should().NotContain(m => m.Contains("GetById") && (m.Contains("2(") || m.Contains("3("))); } @@ -249,20 +249,20 @@ public async Task Test_MultipleInterfacesByTag_DuplicateOperationIds_NoGlobalCou // This is the core bug test: operationIds are identical across tags // but method names should NOT have global numbering string generatedCode = await GenerateCode(); - + // Parse the code to find all method names var lines = generatedCode.Split('\n'); var methodNames = lines .Where(l => l.Contains("Task") && l.Contains("(")) .Select(l => l.Trim()) .ToList(); - + // CRITICAL: No method should have numeric suffixes from global counter // Each interface should have clean method names based on their operationIds methodNames.Should().Contain(m => m.Contains("GetAllUsers(")); methodNames.Should().Contain(m => m.Contains("GetAllProducts(")); methodNames.Should().Contain(m => m.Contains("GetAllOrders(")); - + // The bug would manifest as GetAllUsers, GetAllProducts2, GetAllOrders3 // We should NOT see this: methodNames.Should().NotContain(m => m.Contains("GetAllProducts2("), @@ -278,7 +278,7 @@ public async Task Test_MultipleInterfacesByTag_NoConflict_WithinInterface() { // Test that a single operation in an interface doesn't get numbered string generatedCode = await GenerateCode(); - + // GetOrderById appears only once in Orders interface // Should NOT be GetOrderById1 generatedCode.Should().Contain("Task GetOrderById("); @@ -291,24 +291,24 @@ public async Task Test_MultipleInterfacesByTag_EachInterface_HasOwnNamespace() { // Verify method naming is scoped per interface, not globally string generatedCode = await GenerateCode(); - + // Extract interfaces var usersInterfaceStart = generatedCode.IndexOf("partial interface IUsersApi", StringComparison.Ordinal); var productsInterfaceStart = generatedCode.IndexOf("partial interface IProductsApi", StringComparison.Ordinal); var ordersInterfaceStart = generatedCode.IndexOf("partial interface IOrdersApi", StringComparison.Ordinal); - + usersInterfaceStart.Should().BeGreaterThan(0); productsInterfaceStart.Should().BeGreaterThan(0); ordersInterfaceStart.Should().BeGreaterThan(0); - + // Each interface should have its own clean set of methods var usersInterface = generatedCode.Substring(usersInterfaceStart, productsInterfaceStart - usersInterfaceStart); var productsInterface = generatedCode.Substring(productsInterfaceStart, ordersInterfaceStart - productsInterfaceStart); - + // Users interface should have GetAllUsers without any suffix usersInterface.Should().Contain("GetAllUsers("); usersInterface.Should().NotContain("GetAllUsers2"); - + // Products interface should have GetAllProducts without any suffix productsInterface.Should().Contain("GetAllProducts("); productsInterface.Should().NotContain("GetAllProducts2"); diff --git a/src/Refitter.Tests/Examples/NullableStringPropertyTests.cs b/src/Refitter.Tests/Examples/NullableStringPropertyTests.cs index 47c8dc861..f9df01910 100644 --- a/src/Refitter.Tests/Examples/NullableStringPropertyTests.cs +++ b/src/Refitter.Tests/Examples/NullableStringPropertyTests.cs @@ -113,7 +113,7 @@ public async Task Test_Nullable_StringProperty_GeneratedWithQuestionMark() "state property is marked nullable: true"); generatedCode.Should().Contain("public string? Country { get; init; }", "country property is marked nullable: true"); - + // Street and PostalCode should be non-nullable string (nullable: false) generatedCode.Should().Contain("public string Street { get; init; }", "street property is marked nullable: false"); @@ -157,7 +157,7 @@ public async Task Test_Nullable_StringInRequired_Field_Still_Nullable() // Should STILL generate as string? (nullable: true takes precedence) generatedCode.Should().Contain("public string? MiddleName { get; init; }", "middleName is in required array but marked nullable: true - nullable should take precedence"); - + // FirstName is in required AND nullable: false generatedCode.Should().Contain("public string FirstName { get; init; }", "firstName is in required array and marked nullable: false"); @@ -195,14 +195,14 @@ public async Task Test_Multiple_Nullable_Properties_In_Same_Model() // Assert - Verify Address class has correct mix of nullable and non-nullable var addressClassStart = generatedCode.IndexOf("class Address", StringComparison.Ordinal); addressClassStart.Should().BeGreaterThan(0); - + var addressClassEnd = generatedCode.IndexOf("}", addressClassStart); var addressClass = generatedCode.Substring(addressClassStart, addressClassEnd - addressClassStart); - + // Count nullable properties var nullableStringCount = System.Text.RegularExpressions.Regex.Matches(addressClass, @"string\?").Count; nullableStringCount.Should().Be(3, "Address should have 3 nullable string properties (City, State, Country)"); - + var nullableDoubleCount = System.Text.RegularExpressions.Regex.Matches(addressClass, @"double\?").Count; nullableDoubleCount.Should().Be(2, "Address should have 2 nullable double properties (Latitude, Longitude)"); } diff --git a/src/Refitter.Tests/SourceGeneratorFileIOTests.cs b/src/Refitter.Tests/SourceGeneratorFileIOTests.cs index 0376d4b1d..c5f490068 100644 --- a/src/Refitter.Tests/SourceGeneratorFileIOTests.cs +++ b/src/Refitter.Tests/SourceGeneratorFileIOTests.cs @@ -64,11 +64,11 @@ public async Task Test_SourceGenerator_DoesNotWriteToFileSystem() // Assert - Verify no new .g.cs files were created var finalOutputFiles = Directory.GetFiles(outputDir, "*.g.cs"); var finalFileCount = finalOutputFiles.Length; - + // No new files should have been written to disk during generation - finalFileCount.Should().Be(initialFileCount, + finalFileCount.Should().Be(initialFileCount, "source generator should use context.AddSource() not File.WriteAllText()"); - + // But generated code should exist in memory generatedCode.Should().NotBeNullOrWhiteSpace(); generatedCode.Should().Contain("partial interface ITestApi"); @@ -79,7 +79,7 @@ public async Task Test_SourceGenerator_WithApiDescriptionServer_NoFileConflicts( { // This tests that concurrent generation doesn't cause file I/O conflicts // When using context.AddSource(), multiple generators can run in parallel safely - + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); var settings = new RefitGeneratorSettings { @@ -123,7 +123,7 @@ public async Task Test_SourceGenerator_GeneratedCodeIsValid() // Assert - Generated code should compile generatedCode.Should().Contain("partial interface ITestApi"); generatedCode.Should().Contain("Task>> GetUsers("); - + BuildHelper .BuildCSharp(generatedCode) .Should() @@ -153,7 +153,7 @@ public async Task Test_SourceGenerator_OutputFilename_NotCreatedOnDisk() // Assert - The OutputFilename should NOT create a file on disk File.Exists(expectedFilePath).Should().BeFalse( "OutputFilename should be used as hint name for context.AddSource(), not as a file path"); - + generatedCode.Should().NotBeNullOrWhiteSpace(); } } diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index ef2e38d31..126ba41ce 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -323,6 +323,7 @@ private static RefitGeneratorSettings CreateRefitGeneratorSettings(Settings sett IntegerType = settings.IntegerType }, CustomTemplateDirectory = settings.CustomTemplateDirectory, + GenerateJsonSerializerContext = settings.GenerateJsonSerializerContext, }; } private static async Task WriteSingleFile( diff --git a/src/Refitter/Settings.cs b/src/Refitter/Settings.cs index 3d5cc547f..5049ae988 100644 --- a/src/Refitter/Settings.cs +++ b/src/Refitter/Settings.cs @@ -273,4 +273,9 @@ payloads with (yet) unknown types are offered by newer versions of an API [CommandOption("--custom-template-directory")] [DefaultValue(null)] public string? CustomTemplateDirectory { get; set; } = null; + + [Description("Generate JsonSerializerContext for AOT compilation support")] + [CommandOption("--json-serializer-context")] + [DefaultValue(false)] + public bool GenerateJsonSerializerContext { get; set; } } diff --git a/test-anytype.json b/test-anytype.json new file mode 100644 index 000000000..ae2e98bab --- /dev/null +++ b/test-anytype.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "version": "1.0.0" + }, + "paths": { + "/some/endpoint": { + "post": { + "summary": "Create message with untyped payload", + "operationId": "createMessage", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + } + } +} diff --git a/test-output.cs b/test-output.cs new file mode 100644 index 000000000..798ec4d21 --- /dev/null +++ b/test-output.cs @@ -0,0 +1,79 @@ +// +// This code was generated by Refitter. +// + + +using Refit; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +#nullable enable annotations + +namespace TestNS +{ + /// Example API + [System.CodeDom.Compiler.GeneratedCode("Refitter", "1.0.0.0")] + public partial interface IExampleAPI + { + /// Create message with untyped payload + /// body parameter + /// Success + /// Thrown when the request returns a non-success status code. + [Headers("Accept: application/json", "Content-Type: application/json")] + [Post("/some/endpoint")] + Task CreateMessage([Body(BodySerializationMethod.Serialized)] object body); + + + } + +} + +//---------------------- +// +// Generated using the NSwag toolchain v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace TestNS +{ + using System = global::System; + + + + + + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file