diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5565a864a..d64d2753f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -151,6 +151,7 @@ dotnet run --project src/Refitter --configuration Release --framework net9.0 -- - `--disposable`: Generate IDisposable clients - `--collection-format`: Control query parameter collection formatting (Multi/Csv/Ssv/Tsv/Pipes) - `--no-banner`: Hide donation banner in CLI output +- `--integer-type`: Set the .NET type for OpenAPI integers without a format specifier (Int32/Int64) ### Working with OpenAPI Specifications - Test resources are located in `src/Refitter.Tests/Resources/V2/` and `src/Refitter.Tests/Resources/V3/` diff --git a/README.md b/README.md index 0ca63fe64..8e624d0c9 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ OPTIONS: See https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism for more information --disposable Generate refit clients that implement IDisposable --no-inline-json-converters Don't inline JsonConverter attributes for enum properties. When disabled, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes + --integer-type int The .NET type to use for OpenAPI integer types without a format specifier. Common values: 'int' (default), 'long' ``` To generate code from an OpenAPI specifications file, run the following: @@ -291,6 +292,7 @@ The following is an example `.refitter` file "dictionaryInstanceType": "System.Collections.Generic.Dictionary", "arrayBaseType": "System.Collections.ObjectModel.Collection", "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "integerType": "Int32", // Optional. Default="Int32". The .NET type for OpenAPI integers without a format. Possible values: "Int32", "Int64" "propertySetterAccessModifier": "", "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, @@ -378,6 +380,7 @@ The following is an example `.refitter` file - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `integerType` - Default is `Int32`. The .NET type to use for OpenAPI integer types without a format specifier. Possible values: `Int32`, `Int64` - `propertySetterAccessModifier` - Default is ``, - `generateImmutableArrayProperties` - Default is false, - `generateImmutableDictionaryProperties` - Default is false, diff --git a/docs/docfx_project/articles/cli-tool.md b/docs/docfx_project/articles/cli-tool.md index ac54cbae5..ec530f342 100644 --- a/docs/docfx_project/articles/cli-tool.md +++ b/docs/docfx_project/articles/cli-tool.md @@ -42,6 +42,7 @@ EXAMPLES: refitter ./openapi.json --collection-format Csv refitter ./openapi.json --simple-output refitter ./openapi.json --no-inline-json-converters + refitter ./openapi.json --integer-type long ARGUMENTS: [URL or input file] URL or file path to OpenAPI Specification file @@ -118,6 +119,7 @@ OPTIONS: See https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism for more information --disposable Generate refit clients that implement IDisposable --no-inline-json-converters Don't inline JsonConverter attributes for enum properties. When disabled, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes + --integer-type int The .NET type to use for OpenAPI integer types without a format specifier. Common values: 'int' (default), 'long' ``` ## CLI Tool Output Example diff --git a/docs/docfx_project/articles/refitter-file-format.md b/docs/docfx_project/articles/refitter-file-format.md index 40c9df13e..7159490c1 100644 --- a/docs/docfx_project/articles/refitter-file-format.md +++ b/docs/docfx_project/articles/refitter-file-format.md @@ -99,6 +99,7 @@ The following is an example `.refitter` file "dictionaryInstanceType": "System.Collections.Generic.Dictionary", "arrayBaseType": "System.Collections.ObjectModel.Collection", "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "integerType": "Int32", // Optional. Default="Int32". The .NET type for OpenAPI integers without a format. Possible values: "Int32", "Int64" "propertySetterAccessModifier": "", "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, @@ -192,6 +193,7 @@ The following is an example `.refitter` file - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `integerType` - Default is `Int32`. The .NET type to use for OpenAPI integer types without a format specifier. Possible values: `Int32`, `Int64` - `propertySetterAccessModifier` - Default is ``, - `generateImmutableArrayProperties` - Default is false, - `generateImmutableDictionaryProperties` - Default is false, diff --git a/docs/json-schema.json b/docs/json-schema.json index 22fd00a73..0ea7abd18 100644 --- a/docs/json-schema.json +++ b/docs/json-schema.json @@ -103,6 +103,11 @@ "description": "Gets or sets the generic dictionary .NET type which is used as base class (default: 'Dictionary').", "type": "string" }, + "integerType": { + "description": "Gets or sets the .NET type for OpenAPI integers without a format specifier (default: Int32). Possible values: Int32, Int64.", + "type": "string", + "enum": ["Int32", "Int64"] + }, "propertySetterAccessModifier": { "description": "Gets the access modifier of property setters (default: '').", "type": "string" diff --git a/src/Refitter.Core/CSharpClientGeneratorFactory.cs b/src/Refitter.Core/CSharpClientGeneratorFactory.cs index ed259c4a7..adfe4085f 100644 --- a/src/Refitter.Core/CSharpClientGeneratorFactory.cs +++ b/src/Refitter.Core/CSharpClientGeneratorFactory.cs @@ -1,4 +1,5 @@ using System.Reflection; +using NJsonSchema; using NJsonSchema.CodeGeneration; using NJsonSchema.CodeGeneration.CSharp; using NSwag; @@ -18,6 +19,8 @@ public CustomCSharpClientGenerator Create() } } + ApplyCustomIntegerType(); + var csharpClientGeneratorSettings = new CSharpClientGeneratorSettings { GenerateClientClasses = false, @@ -60,6 +63,97 @@ public CustomCSharpClientGenerator Create() return generator; } + private void ApplyCustomIntegerType() + { + var customIntegerType = settings.CodeGeneratorSettings?.IntegerType ?? IntegerType.Int32; + if (customIntegerType == IntegerType.Int32) + return; + + ProcessSchemasForIntegerType(document.Components.Schemas); + + foreach (var path in document.Paths) + { + foreach (var operation in path.Value.Values) + { + if (operation == null) continue; + + foreach (var parameter in operation.Parameters) + { + if (parameter.ActualSchema.Type == JsonObjectType.Integer && + string.IsNullOrEmpty(parameter.ActualSchema.Format)) + { + parameter.ActualSchema.Format = "int64"; + } + } + + if (operation.RequestBody?.Content != null) + { + foreach (var content in operation.RequestBody.Content.Values) + { + ProcessSchemaForIntegerType(content.Schema); + } + } + + foreach (var response in operation.Responses.Values) + { + if (response.Content != null) + { + foreach (var content in response.Content.Values) + { + ProcessSchemaForIntegerType(content.Schema); + } + } + } + } + } + } + + private void ProcessSchemasForIntegerType(IDictionary schemas) + { + foreach (var schema in schemas.Values) + { + ProcessSchemaForIntegerType(schema); + } + } + + private void ProcessSchemaForIntegerType(JsonSchema? schema) + { + if (schema == null) return; + + var actualSchema = schema.ActualSchema; + + if (actualSchema.Type == JsonObjectType.Integer && + string.IsNullOrEmpty(actualSchema.Format)) + { + actualSchema.Format = "int64"; + } + + foreach (var property in actualSchema.Properties.Values) + { + ProcessSchemaForIntegerType(property); + } + + if (actualSchema.Item != null) + { + ProcessSchemaForIntegerType(actualSchema.Item); + } + + if (actualSchema.AdditionalPropertiesSchema != null) + { + ProcessSchemaForIntegerType(actualSchema.AdditionalPropertiesSchema); + } + + var subSchemas = actualSchema.AllOf + .Concat(actualSchema.OneOf) + .Concat(actualSchema.AnyOf) + .ToArray(); + + foreach (var subSchema in subSchemas) + { + ProcessSchemaForIntegerType(subSchema); + } + } + private static void MapCSharpGeneratorSettings( CodeGeneratorSettings? source, CSharpGeneratorSettings destination) diff --git a/src/Refitter.Core/IntegerType.cs b/src/Refitter.Core/IntegerType.cs new file mode 100644 index 000000000..caca60299 --- /dev/null +++ b/src/Refitter.Core/IntegerType.cs @@ -0,0 +1,18 @@ +namespace Refitter.Core; + +/// +/// Specifies the .NET type to use for OpenAPI integer types +/// without a format specifier +/// +public enum IntegerType +{ + /// + /// Use System.Int32 (int) for integers without format + /// + Int32, + + /// + /// Use System.Int64 (long) for integers without format + /// + Int64 +} diff --git a/src/Refitter.Core/Settings/CodeGeneratorSettings.cs b/src/Refitter.Core/Settings/CodeGeneratorSettings.cs index fd3e7b359..412a18634 100644 --- a/src/Refitter.Core/Settings/CodeGeneratorSettings.cs +++ b/src/Refitter.Core/Settings/CodeGeneratorSettings.cs @@ -58,6 +58,12 @@ Gets or sets a value indicating whether a required property must be defined in J [Description("Gets or sets the time span .NET type (default: 'TimeSpan').")] public string TimeSpanType { get; set; } = "System.TimeSpan"; + /// + /// Gets or sets the .NET type for OpenAPI integers without a format specifier (default: Int32). + /// + [Description("Gets or sets the .NET type for OpenAPI integers without a format specifier (default: Int32).")] + public IntegerType IntegerType { get; set; } = IntegerType.Int32; + /// /// Gets or sets the generic array .NET type (default: 'ICollection'). /// diff --git a/src/Refitter.MSBuild/README.md b/src/Refitter.MSBuild/README.md index 8e7ae98e7..a9e1a7dfd 100644 --- a/src/Refitter.MSBuild/README.md +++ b/src/Refitter.MSBuild/README.md @@ -105,6 +105,7 @@ The following is an example `.refitter` file "dictionaryInstanceType": "System.Collections.Generic.Dictionary", "arrayBaseType": "System.Collections.ObjectModel.Collection", "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "integerType": "Int32", // Optional. Default="Int32". The .NET type for OpenAPI integers without a format. Possible values: "Int32", "Int64" "propertySetterAccessModifier": "", "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, @@ -181,6 +182,7 @@ The following is an example `.refitter` file - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `integerType` - Default is `Int32`. The .NET type to use for OpenAPI integer types without a format specifier. Possible values: `Int32`, `Int64` - `propertySetterAccessModifier` - Default is ``, - `generateImmutableArrayProperties` - Default is false, - `generateImmutableDictionaryProperties` - Default is false, diff --git a/src/Refitter.SourceGenerator/README.md b/src/Refitter.SourceGenerator/README.md index 170a1ae58..f93d09efc 100644 --- a/src/Refitter.SourceGenerator/README.md +++ b/src/Refitter.SourceGenerator/README.md @@ -101,6 +101,7 @@ The following is an example `.refitter` file "dictionaryInstanceType": "System.Collections.Generic.Dictionary", "arrayBaseType": "System.Collections.ObjectModel.Collection", "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "integerType": "int", // Optional. Default="int". The .NET type for OpenAPI integers without a format. Common values: "int", "long" "propertySetterAccessModifier": "", "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, @@ -179,6 +180,7 @@ The following is an example `.refitter` file - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `integerType` - Default is `int`. The .NET type to use for OpenAPI integer types without a format specifier. Common values: `int`, `long` - `propertySetterAccessModifier` - Default is ``, - `generateImmutableArrayProperties` - Default is false, - `generateImmutableDictionaryProperties` - Default is false, diff --git a/src/Refitter.Tests/Examples/IntegerFormatTests.cs b/src/Refitter.Tests/Examples/IntegerFormatTests.cs new file mode 100644 index 000000000..1b4d5cf25 --- /dev/null +++ b/src/Refitter.Tests/Examples/IntegerFormatTests.cs @@ -0,0 +1,269 @@ +using FluentAssertions; +using Refitter.Core; +using Refitter.Tests.Build; +using Refitter.Tests.TestUtilities; +using Xunit; + +namespace Refitter.Tests.Examples; + +public class IntegerFormatTests +{ + private const string OpenApiSpec = + """ + { + "openapi": "3.0.1", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "operationId": "TestEndpoint", + "parameters": [ + { + "name": "integerWithoutFormat", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "integerWithInt32Format", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "integerWithInt64Format", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestModel" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseModel" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "RequestModel": { + "type": "object", + "properties": { + "integerWithoutFormat": { + "type": "integer", + "description": "Integer without format - should default to int but optionally be long" + }, + "integerWithInt32Format": { + "type": "integer", + "format": "int32" + }, + "integerWithInt64Format": { + "type": "integer", + "format": "int64" + }, + "additionalPropsObject": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "allOfExample": { + "allOf": [ + { "$ref": "#/components/schemas/BaseType" }, + { + "type": "object", + "properties": { + "extra": { "type": "string" } + } + } + ] + }, + "oneOfExample": { + "oneOf": [ + { "$ref": "#/components/schemas/TypeA" }, + { "$ref": "#/components/schemas/TypeB" } + ] + }, + "anyOfExample": { + "anyOf": [ + { "$ref": "#/components/schemas/TypeA" }, + { "$ref": "#/components/schemas/TypeB" } + ] + } + } + }, + "ResponseModel": { + "type": "object", + "properties": { + "integerWithoutFormat": { + "type": "integer", + "description": "Integer without format - should default to int but optionally be long" + }, + "integerWithInt32Format": { + "type": "integer", + "format": "int32" + }, + "integerWithInt64Format": { + "type": "integer", + "format": "int64" + }, + "additionalPropsObject": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "allOfExample": { + "allOf": [ + { "$ref": "#/components/schemas/BaseType" }, + { + "type": "object", + "properties": { + "extra": { "type": "integer" } + } + } + ] + }, + "oneOfExample": { + "oneOf": [ + { "$ref": "#/components/schemas/TypeA" }, + { "$ref": "#/components/schemas/TypeB" } + ] + }, + "anyOfExample": { + "anyOf": [ + { "$ref": "#/components/schemas/TypeA" }, + { "$ref": "#/components/schemas/TypeB" } + ] + } + } + }, + "BaseType": { + "type": "object", + "properties": { + "baseProp": { "type": "integer" } + } + }, + "TypeA": { + "type": "object", + "properties": { + "typeAProp": { "type": "integer" } + } + }, + "TypeB": { + "type": "object", + "properties": { + "typeBProp": { "type": "integer" } + } + } + } + } + } + """; + + [Fact] + public async Task Default_Generates_Integer_Without_Format_As_Int() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().Contain("int IntegerWithoutFormat"); + } + + [Fact] + public async Task Can_Generate_Integer_Without_Format_As_Long() + { + string generatedCode = await GenerateCodeWithLongIntegers(); + generatedCode.Should().Contain("long IntegerWithoutFormat"); + } + + [Fact] + public async Task Always_Respects_Explicit_Int32_Format() + { + string generatedCode = await GenerateCodeWithLongIntegers(); + generatedCode.Should().Contain("int IntegerWithInt32Format"); + } + + [Fact] + public async Task Always_Respects_Explicit_Int64_Format() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().Contain("long IntegerWithInt64Format"); + } + + [Fact] + public async Task Can_Build_Generated_Code_With_Default() + { + string generatedCode = await GenerateCode(); + BuildHelper + .BuildCSharp(generatedCode) + .Should() + .BeTrue(); + } + + [Fact] + public async Task Can_Build_Generated_Code_With_Long() + { + string generatedCode = await GenerateCodeWithLongIntegers(); + BuildHelper + .BuildCSharp(generatedCode) + .Should() + .BeTrue(); + } + + private static async Task GenerateCode() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + }; + + var sut = await RefitGenerator.CreateAsync(settings); + var generatedCode = sut.Generate(); + return generatedCode; + } + + private static async Task GenerateCodeWithLongIntegers() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + CodeGeneratorSettings = new CodeGeneratorSettings + { + IntegerType = IntegerType.Int64 + } + }; + + var sut = await RefitGenerator.CreateAsync(settings); + var generatedCode = sut.Generate(); + return generatedCode; + } +} diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index 071eec1a3..a3baa3518 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -311,7 +311,8 @@ private static RefitGeneratorSettings CreateRefitGeneratorSettings(Settings sett GenerateXmlDocCodeComments = !settings.NoXmlDocCodeComments, CodeGeneratorSettings = new CodeGeneratorSettings { - InlineJsonConverters = !settings.NoInlineJsonConverters + InlineJsonConverters = !settings.NoInlineJsonConverters, + IntegerType = settings.IntegerType } }; } diff --git a/src/Refitter/README.md b/src/Refitter/README.md index ff0b3c353..4d39a5afb 100644 --- a/src/Refitter/README.md +++ b/src/Refitter/README.md @@ -44,6 +44,7 @@ EXAMPLES: refitter ./openapi.json --optional-nullable-parameters refitter ./openapi.json --use-polymorphic-serialization refitter ./openapi.json --no-inline-json-converters + refitter ./openapi.json --integer-type long ARGUMENTS: [URL or input file] URL or file path to OpenAPI Specification file @@ -113,6 +114,7 @@ OPTIONS: See https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism for more information --disposable Generate refit clients that implement IDisposable --no-inline-json-converters Don't inline JsonConverter attributes for enum properties. When disabled, enum properties will not have [JsonConverter(typeof(JsonStringEnumConverter))] attributes + --integer-type int The .NET type to use for OpenAPI integer types without a format specifier. Common values: 'int' (default), 'long' ``` ## CLI Tool Output Example @@ -215,6 +217,7 @@ The following is an example `.refitter` file "dictionaryInstanceType": "System.Collections.Generic.Dictionary", "arrayBaseType": "System.Collections.ObjectModel.Collection", "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "integerType": "int", // Optional. Default="int". The .NET type for OpenAPI integers without a format. Common values: "int", "long" "propertySetterAccessModifier": "", "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, @@ -299,6 +302,7 @@ The following is an example `.refitter` file - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `integerType` - Default is `int`. The .NET type to use for OpenAPI integer types without a format specifier. Common values: `int`, `long` - `propertySetterAccessModifier` - Default is ``, - `generateImmutableArrayProperties` - Default is false, - `generateImmutableDictionaryProperties` - Default is false, diff --git a/src/Refitter/Settings.cs b/src/Refitter/Settings.cs index 9b4aebf0d..1886d6387 100644 --- a/src/Refitter/Settings.cs +++ b/src/Refitter/Settings.cs @@ -242,7 +242,7 @@ payloads with (yet) unknown types are offered by newer versions of an API - Csv: Comma-separated values. (?param=value1,value2) - Ssv: Space-separated values. (?param=value1 value2) - Tsv: Tab-separated values. (?param=value1\tvalue2) - - Pipes: Pipe-separated values. (?param=value1|value2) + - Pipes: Pipe-separated values. (?param=value1|value2) See https://swagger.io/docs/specification/v2_0/describing-parameters/#array-and-multi-value-parameters for more information. """)] [CommandOption("--collection-format")] @@ -258,4 +258,9 @@ payloads with (yet) unknown types are offered by newer versions of an API [CommandOption("--no-inline-json-converters")] [DefaultValue(false)] public bool NoInlineJsonConverters { get; set; } + + [Description("The .NET type to use for OpenAPI integers without a format specifier. May be one of: Int32, Int64")] + [CommandOption("--integer-type")] + [DefaultValue(IntegerType.Int32)] + public IntegerType IntegerType { get; set; } = IntegerType.Int32; }