From c209736799840e75e2d5147a32f77630fb9eecf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:38:53 +0000 Subject: [PATCH 1/9] feat: generate CLI enum types for top-level named enum schemas OpenAPI schemas with type: string/integer and an enum keyword now produce real CLI enum ProvidedTypeDefinitions instead of plain string/int aliases. This gives callers compile-time type safety and enables exhaustive matching. - DefinitionCompiler: detect top-level named string/integer enum schemas and emit ProvidedTypeDefinition with base type System.Enum. String enums receive [JsonConverter(typeof)] and each member gets [JsonPropertyName(originalValue)] for round-trip serialization. Integer enum members use the numeric values from the schema. - RuntimeHelpers: add buildEnumSerializer (cached per type), which handles both string enums (via JsonPropertyName lookup) and integer enums (via Convert.ChangeType). The toParam function dispatches to this path when the boxed value's type IsEnum. - Tests: 10 new Schema.TypeMappingTests covering enum type identity, member names, integer values, JSON attribute presence, and optional/required wrapping; 8 new RuntimeHelpersTests covering toParam for string enum wire values, hyphenated names, integer enums, and Option unwrapping. Closes #146 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefinitionCompiler.fs | 76 +++++++ src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 65 ++++++ .../RuntimeHelpersTests.fs | 59 ++++++ .../Schema.TypeMappingTests.fs | 200 ++++++++++++++++++ 4 files changed, 400 insertions(+) diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index 31ac440a..dd33c22c 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -2,6 +2,8 @@ namespace SwaggerProvider.Internal.Compilers open System open System.Reflection +open System.Text.Json +open System.Text.Json.Nodes open ProviderImplementation.ProvidedTypes open UncheckedQuotations open FSharp.Data.Runtime.NameUtils @@ -512,6 +514,80 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b || resolvedType = Some(JsonSchemaType.Null ||| JsonSchemaType.Object) -> compileNewObject() + | _ when + fromByPathCompiler + && not(isNull tyName) + && (resolvedType = Some JsonSchemaType.String + || resolvedType = Some JsonSchemaType.Integer) + && not(isNull schemaObj.Enum) + && schemaObj.Enum.Count > 0 + -> + // Top-level named enum schema: generate a CLI enum type so callers have + // compile-time type safety instead of raw string/int values. + let isStringEnum = resolvedType = Some JsonSchemaType.String + + let enumTy = + ProvidedTypeDefinition(tyName, Some typeof, isErased = false) + + enumTy.SetEnumUnderlyingType typeof + + // String enums are serialized via JsonStringEnumConverter, which reads the + // [JsonPropertyName] attribute on each member to get the wire value. + if isStringEnum then + enumTy.AddCustomAttribute + <| RuntimeHelpers.getJsonStringEnumConverterAttribute() + + let nameGen = UniqueNameGenerator() + let mutable intValue = 0 + + for node in schemaObj.Enum do + let rawValueOpt = + match node with + | :? JsonValue as jv -> + match jv.GetValueKind() with + | JsonValueKind.String -> if isStringEnum then Some(jv.GetValue()) else None + | JsonValueKind.Number -> if isStringEnum then None else Some(jv.ToString()) + | JsonValueKind.Null -> None + | _ -> None + | _ when not(isNull node) -> Some(node.ToString()) + | _ -> None + + match rawValueOpt with + | None -> () + | Some originalStr -> + // Sanitize to a valid .NET identifier. + let rawName = nicePascalName originalStr + + let sanitized = + if String.IsNullOrEmpty rawName || Char.IsDigit rawName.[0] then + "V" + rawName + else + rawName + + let memberName = nameGen.MakeUnique sanitized + + let literalValue: obj = + if isStringEnum then + intValue :> obj + else + match Int32.TryParse originalStr with + | true, v -> v :> obj + | false, _ -> intValue :> obj + + let field = ProvidedField.Literal(memberName, enumTy, literalValue) + + // Always add [JsonPropertyName] to string enum members so that + // serialization uses the exact original OpenAPI value regardless of + // how the member name was sanitized. + if isStringEnum then + field.AddCustomAttribute + <| RuntimeHelpers.getPropertyNameAttribute originalStr + + enumTy.AddMember field + intValue <- intValue + 1 + + registerNew(tyName, enumTy :> Type) + enumTy :> Type | _ -> ns.MarkTypeAsNameAlias tyName diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 49beb3f2..786afdfb 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -133,6 +133,51 @@ module RuntimeHelpers = let private optionValueFactory = System.Func(fun t -> t.GetProperty("Value")) + /// Builds an (obj -> string) serializer for CLI enum types. + /// For string enums (annotated with JsonStringEnumConverter): returns the JsonPropertyName + /// value for each member, falling back to the member name. + /// For integer enums: returns the underlying integer value as a string. + let private buildEnumSerializer(ty: Type) : obj -> string = + let jsonConverterAttr = + Attribute.GetCustomAttribute(ty, typeof) :?> JsonConverterAttribute + + let isStringEnum = + not(isNull jsonConverterAttr) + && typeof.IsAssignableFrom(jsonConverterAttr.ConverterType) + + if isStringEnum then + let lookup = + ty.GetFields(Reflection.BindingFlags.Public ||| Reflection.BindingFlags.Static) + |> Array.choose(fun f -> + if f.IsLiteral then + let v = Convert.ToInt32(f.GetRawConstantValue()) + + let jsonPropAttr = + Attribute.GetCustomAttribute(f, typeof) :?> JsonPropertyNameAttribute + + let name = if isNull jsonPropAttr then f.Name else jsonPropAttr.Name + Some(v, name) + else + None) + |> dict + + fun (o: obj) -> + let intValue = Convert.ToInt32 o + + match lookup.TryGetValue intValue with + | true, s -> s + | false, _ -> o.ToString() + else + let underlyingType = Enum.GetUnderlyingType ty + fun (o: obj) -> Convert.ChangeType(o, underlyingType).ToString() + + // Cache of enum type -> (obj -> string) serializer, built lazily per type. + let private enumSerializerCache = + Collections.Concurrent.ConcurrentDictionary string>() + + let private enumSerializerFactory = + System.Func string>(buildEnumSerializer) + let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") @@ -162,6 +207,11 @@ module RuntimeHelpers = toParam(valueProp.GetValue(obj)) else null + elif ty.IsEnum then + // CLI enum type: use the cached serializer so string enums produce their + // original OpenAPI string value and integer enums produce the integer. + let serializer = enumSerializerCache.GetOrAdd(ty, enumSerializerFactory) + serializer obj else obj.ToString() @@ -294,6 +344,21 @@ module RuntimeHelpers = member _.NamedArguments = [||] :> Collections.Generic.IList<_> } + // Cached constructor for JsonConverterAttribute (used to apply JsonStringEnumConverter to generated enum types). + let private jsonConverterCtor = + typeof.GetConstructor [| typeof |] + + /// Builds a CustomAttributeData representing [JsonConverter(typeof)]. + /// Apply this to generated CLI enum types so System.Text.Json serialises them as strings. + let getJsonStringEnumConverterAttribute() = + { new Reflection.CustomAttributeData() with + member _.Constructor = jsonConverterCtor + + member _.ConstructorArguments = + [| Reflection.CustomAttributeTypedArgument(typeof, typeof) |] :> Collections.Generic.IList<_> + + member _.NamedArguments = [||] :> Collections.Generic.IList<_> } + let toStringContent(valueStr: string) = new StringContent(valueStr, Text.Encoding.UTF8, "application/json") diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index edde0397..9ee71c48 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -118,6 +118,65 @@ module ToParamTests = result |> shouldEqual null +// ── CLI enum serialization via toParam ────────────────────────────────────────────────────── +// toParam must serialize string enums to their original OpenAPI wire values and integer +// enums to their underlying integer representation. +module EnumToParamTests = + // String enum: decorated with [JsonConverter(typeof)] and + // each member has [JsonPropertyName("wire-value")] to handle sanitized names. + [)>] + type StringStatus = + | [] Active = 0 + | [] Inactive = 1 + | [] InProgress = 2 + + // Integer enum: no JSON attributes, serializes as the underlying integer. + type IntStatus = + | Pending = 1 + | Running = 2 + | Done = 3 + + [] + let ``toParam serializes string enum to its wire value (exact case)``() = + let result = toParam(box StringStatus.Active) + result |> shouldEqual "active" + + [] + let ``toParam serializes string enum with hyphenated wire value``() = + let result = toParam(box StringStatus.InProgress) + result |> shouldEqual "in-progress" + + [] + let ``toParam serializes string enum inactive value``() = + let result = toParam(box StringStatus.Inactive) + result |> shouldEqual "inactive" + + [] + let ``toParam serializes integer enum to its underlying integer value``() = + let result = toParam(box IntStatus.Pending) + result |> shouldEqual "1" + + [] + let ``toParam serializes integer enum Running to 2``() = + let result = toParam(box IntStatus.Running) + result |> shouldEqual "2" + + [] + let ``toParam unwraps Some(string enum) and returns wire value``() = + let result = toParam(box(Some StringStatus.Active)) + result |> shouldEqual "active" + + [] + let ``toParam returns null for None(string enum)``() = + let result = toParam(box(None: StringStatus option)) + result |> shouldEqual null + + [] + let ``toParam unwraps Some(integer enum) and returns integer string``() = + let result = toParam(box(Some IntStatus.Done)) + result |> shouldEqual "3" + + module ToQueryParamsTests = let private stubClient = diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index e3c3948f..ca66f1b2 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -499,3 +499,203 @@ let ``PreferNullable: required TimeOnly is not wrapped when useDateOnly is true` compilePropertyTypeWithNullableAndDateOnly " type: string\n format: time\n" true ty |> shouldEqual typeof + +// ── Named enum schema generation ────────────────────────────────────────────────────────────── +// When a top-level component schema has `type: string` (or `integer`) plus an `enum` list, +// DefinitionCompiler should emit a CLI enum type instead of a plain string/int. + +let private enumTestSchema(schemaBody: string) = + sprintf + """openapi: "3.0.0" +info: + title: EnumTest + version: "1.0.0" +paths: {} +components: + schemas: + Status: +%s""" + schemaBody + +[] +let ``named string enum schema compiles to a CLI enum type``() = + let schema = + enumTestSchema + """ type: string + enum: + - active + - inactive + - pending""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + statusTy.IsEnum |> shouldEqual true + +[] +let ``named string enum has correct member names``() = + let schema = + enumTestSchema + """ type: string + enum: + - active + - inactive + - pending""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + let memberNames = statusTy.GetFields() |> Array.map(fun f -> f.Name) |> Array.sort + + memberNames |> shouldEqual [| "Active"; "Inactive"; "Pending" |] + +[] +let ``named string enum member values are sequential integers``() = + let schema = + enumTestSchema + """ type: string + enum: + - active + - inactive + - pending""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + + let values = + statusTy.GetFields() + |> Array.filter(fun f -> f.IsLiteral) + |> Array.sortBy(fun f -> f.Name) + |> Array.map(fun f -> f.GetRawConstantValue() :?> int32) + + values |> shouldEqual [| 0; 1; 2 |] + +[] +let ``named string enum has JsonStringEnumConverter attribute``() = + let schema = + enumTestSchema + """ type: string + enum: + - active + - inactive""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + + statusTy.GetCustomAttributesData() + |> Seq.exists(fun a -> a.Constructor.DeclaringType = typeof) + |> shouldEqual true + +[] +let ``named string enum members have JsonPropertyName attributes for wire values``() = + let schema = + enumTestSchema + """ type: string + enum: + - active + - in-active""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + + let jsonNames = + statusTy.GetFields() + |> Array.filter(fun f -> f.IsLiteral) + |> Array.collect(fun f -> + f.GetCustomAttributesData() + |> Seq.filter(fun a -> a.Constructor.DeclaringType = typeof) + |> Seq.map(fun a -> a.ConstructorArguments.[0].Value :?> string) + |> Seq.toArray) + |> Array.sort + + jsonNames |> shouldEqual [| "active"; "in-active" |] + +[] +let ``named integer enum schema compiles to a CLI enum type``() = + let schema = + enumTestSchema + """ type: integer + enum: + - 1 + - 2 + - 3""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + statusTy.IsEnum |> shouldEqual true + +[] +let ``named integer enum has correct integer values``() = + let schema = + enumTestSchema + """ type: integer + enum: + - 10 + - 20 + - 30""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + + let values = + statusTy.GetFields() + |> Array.filter(fun f -> f.IsLiteral) + |> Array.map(fun f -> f.GetRawConstantValue() :?> int32) + |> Array.sort + + values |> shouldEqual [| 10; 20; 30 |] + +[] +let ``optional named string enum property maps to Option``() = + let schema = + """openapi: "3.0.0" +info: + title: EnumTest + version: "1.0.0" +paths: {} +components: + schemas: + Status: + type: string + enum: + - active + - inactive + TestType: + type: object + properties: + status: + $ref: '#/components/schemas/Status'""" + + let types = compileV3Schema schema false + let testType = types |> List.find(fun t -> t.Name = "TestType") + let prop = testType.GetDeclaredProperty("Status") + prop.PropertyType.IsGenericType |> shouldEqual true + + prop.PropertyType.GetGenericTypeDefinition() + |> shouldEqual typedefof> + +[] +let ``required named string enum property maps to enum type directly``() = + let schema = + """openapi: "3.0.0" +info: + title: EnumTest + version: "1.0.0" +paths: {} +components: + schemas: + Status: + type: string + enum: + - active + - inactive + TestType: + type: object + required: + - status + properties: + status: + $ref: '#/components/schemas/Status'""" + + let types = compileV3Schema schema false + let testType = types |> List.find(fun t -> t.Name = "TestType") + let prop = testType.GetDeclaredProperty("Status") + prop.PropertyType.IsEnum |> shouldEqual true From 3f2cd6a98273dac44310bbe5254a088999cebbf7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 27 Apr 2026 13:38:57 +0000 Subject: [PATCH 2/9] ci: trigger checks From a895848950e48cc5bfc181741d42c2ff4f9955c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:07:48 +0000 Subject: [PATCH 3/9] fix: update provider tests to use WebAPI.UriKind enum type instead of string Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/2b859f86-a22d-4020-9c5b-9103b1d6afe4 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../Swashbuckle.ReturnControllers.Tests.fs | 9 +++++---- .../Swashbuckle.UpdateControllers.Tests.fs | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/SwaggerProvider.ProviderTests/Swashbuckle.ReturnControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/Swashbuckle.ReturnControllers.Tests.fs index a8cb1e8b..c2418fab 100644 --- a/tests/SwaggerProvider.ProviderTests/Swashbuckle.ReturnControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/Swashbuckle.ReturnControllers.Tests.fs @@ -101,11 +101,11 @@ let ``Return Guid POST Test``() = [] let ``Return Enum GET Test``() = - api.GetApiReturnEnum() |> asyncEqual "Absolute" + api.GetApiReturnEnum() |> asyncEqual WebAPI.UriKind.Absolute [] let ``Return Enum POST Test``() = - api.PostApiReturnEnum() |> asyncEqual "Absolute" + api.PostApiReturnEnum() |> asyncEqual WebAPI.UriKind.Absolute [] @@ -119,12 +119,13 @@ let ``Return Array Int POST Test``() = [] let ``Return Array Enum GET Test``() = - api.GetApiReturnArrayEnum() |> asyncEqual [| "Absolute"; "Relative" |] + api.GetApiReturnArrayEnum() + |> asyncEqual [| WebAPI.UriKind.Absolute; WebAPI.UriKind.Relative |] [] let ``Return Array Enum POST Test``() = api.PostApiReturnArrayEnum() - |> asyncEqual [| "Absolute"; "Relative" |] + |> asyncEqual [| WebAPI.UriKind.Absolute; WebAPI.UriKind.Relative |] [] diff --git a/tests/SwaggerProvider.ProviderTests/Swashbuckle.UpdateControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/Swashbuckle.UpdateControllers.Tests.fs index 51dd1788..72e116db 100644 --- a/tests/SwaggerProvider.ProviderTests/Swashbuckle.UpdateControllers.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/Swashbuckle.UpdateControllers.Tests.fs @@ -83,11 +83,13 @@ let ``Update Guid POST Test``() = [] let ``Update Enum GET Test``() = - api.GetApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute" + api.GetApiUpdateEnum(Some WebAPI.UriKind.Absolute) + |> asyncEqual WebAPI.UriKind.Absolute [] let ``Update Enum POST Test``() = - api.PostApiUpdateEnum(Some "Absolute") |> asyncEqual "Absolute" + api.PostApiUpdateEnum(Some WebAPI.UriKind.Absolute) + |> asyncEqual WebAPI.UriKind.Absolute [] @@ -101,13 +103,13 @@ let ``Update Array Int POST Test``() = [] let ``Update Array Enum GET Test``() = - api.GetApiUpdateArrayEnum([| "Relative"; "Absolute" |]) - |> asyncEqual [| "Absolute"; "Relative" |] + api.GetApiUpdateArrayEnum([| WebAPI.UriKind.Relative; WebAPI.UriKind.Absolute |]) + |> asyncEqual [| WebAPI.UriKind.Absolute; WebAPI.UriKind.Relative |] [] let ``Update Array Enum POST Test``() = - api.PostApiUpdateArrayEnum([| "Relative"; "Absolute" |]) - |> asyncEqual [| "Absolute"; "Relative" |] + api.PostApiUpdateArrayEnum([| WebAPI.UriKind.Relative; WebAPI.UriKind.Absolute |]) + |> asyncEqual [| WebAPI.UriKind.Absolute; WebAPI.UriKind.Relative |] [] let ``Update Array Guid GET Test``() = From 5318b604cbdd9a51ce6b36cfc0e914341eff3d15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:21:43 +0000 Subject: [PATCH 4/9] feat: add Priority enum (4 cases) provider tests and fix enum array query param serialization Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/9b5046fc-d426-4c48-b89c-8716b4dd4ac0 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 2 +- .../SwaggerProvider.ProviderTests.fsproj | 1 + ...Swashbuckle.CustomEnumControllers.Tests.fs | 68 +++++++++++++++++++ .../Controllers/ReturnControllers.fs | 12 ++++ .../Controllers/Types.fs | 6 ++ .../Controllers/UpdateControllers.fs | 6 ++ 6 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/SwaggerProvider.ProviderTests/Swashbuckle.CustomEnumControllers.Tests.fs diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 786afdfb..2d320685 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -247,7 +247,7 @@ module RuntimeHelpers = | :? Array as xs when xs.GetType().GetElementType() |> Option.ofObj - |> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t) + |> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t || t.IsEnum) -> xs |> Seq.cast diff --git a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj index e37df186..8c4f9bfb 100644 --- a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj +++ b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj @@ -26,6 +26,7 @@ + APIs.guru.fs diff --git a/tests/SwaggerProvider.ProviderTests/Swashbuckle.CustomEnumControllers.Tests.fs b/tests/SwaggerProvider.ProviderTests/Swashbuckle.CustomEnumControllers.Tests.fs new file mode 100644 index 00000000..f5d3d443 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/Swashbuckle.CustomEnumControllers.Tests.fs @@ -0,0 +1,68 @@ +module Swashbuckle.CustomEnumControllersTests + +open Xunit +open Swashbuckle.ReturnControllersTests + +[] +let ``Return Priority GET Test``() = + api.GetApiReturnPriority() |> asyncEqual WebAPI.Priority.High + +[] +let ``Return Priority POST Test``() = + api.PostApiReturnPriority() |> asyncEqual WebAPI.Priority.High + +[] +let ``Return Array Priority GET Test``() = + api.GetApiReturnArrayPriority() + |> asyncEqual + [| WebAPI.Priority.Low + WebAPI.Priority.Normal + WebAPI.Priority.High + WebAPI.Priority.Critical |] + +[] +let ``Return Array Priority POST Test``() = + api.PostApiReturnArrayPriority() + |> asyncEqual + [| WebAPI.Priority.Low + WebAPI.Priority.Normal + WebAPI.Priority.High + WebAPI.Priority.Critical |] + +[] +let ``Update Priority GET Test``() = + api.GetApiUpdatePriority(Some WebAPI.Priority.Critical) + |> asyncEqual WebAPI.Priority.Critical + +[] +let ``Update Priority POST Test``() = + api.PostApiUpdatePriority(Some WebAPI.Priority.Critical) + |> asyncEqual WebAPI.Priority.Critical + +[] +let ``Update Array Priority GET Test``() = + api.GetApiUpdateArrayPriority( + [| WebAPI.Priority.Critical + WebAPI.Priority.High + WebAPI.Priority.Normal + WebAPI.Priority.Low |] + ) + |> asyncEqual + [| WebAPI.Priority.Low + WebAPI.Priority.Normal + WebAPI.Priority.High + WebAPI.Priority.Critical |] + +[] +let ``Update Array Priority POST Test``() = + api.PostApiUpdateArrayPriority( + [| WebAPI.Priority.Critical + WebAPI.Priority.High + WebAPI.Priority.Normal + WebAPI.Priority.Low |] + ) + |> asyncEqual + [| WebAPI.Priority.Low + WebAPI.Priority.Normal + WebAPI.Priority.High + WebAPI.Priority.Critical |] diff --git a/tests/Swashbuckle.WebApi.Server/Controllers/ReturnControllers.fs b/tests/Swashbuckle.WebApi.Server/Controllers/ReturnControllers.fs index f5ced406..2d67d688 100644 --- a/tests/Swashbuckle.WebApi.Server/Controllers/ReturnControllers.fs +++ b/tests/Swashbuckle.WebApi.Server/Controllers/ReturnControllers.fs @@ -51,6 +51,18 @@ type ReturnArrayIntController() = type ReturnArrayEnumController() = inherit ReturnController([| System.UriKind.Absolute; System.UriKind.Relative |]) +type ReturnPriorityController() = + inherit ReturnController(Types.Priority.High) + +type ReturnArrayPriorityController() = + inherit + ReturnController( + [| Types.Priority.Low + Types.Priority.Normal + Types.Priority.High + Types.Priority.Critical |] + ) + type ReturnListIntController() = inherit ReturnController([ 1; 2; 3 ]) diff --git a/tests/Swashbuckle.WebApi.Server/Controllers/Types.fs b/tests/Swashbuckle.WebApi.Server/Controllers/Types.fs index 9a299b67..75e507d4 100644 --- a/tests/Swashbuckle.WebApi.Server/Controllers/Types.fs +++ b/tests/Swashbuckle.WebApi.Server/Controllers/Types.fs @@ -21,3 +21,9 @@ type FileDescription(name: string, bytes: byte[]) = [] member val Bytes = bytes with get, set + +type Priority = + | Low = 0 + | Normal = 1 + | High = 2 + | Critical = 3 diff --git a/tests/Swashbuckle.WebApi.Server/Controllers/UpdateControllers.fs b/tests/Swashbuckle.WebApi.Server/Controllers/UpdateControllers.fs index a5d05e76..1c54e868 100644 --- a/tests/Swashbuckle.WebApi.Server/Controllers/UpdateControllers.fs +++ b/tests/Swashbuckle.WebApi.Server/Controllers/UpdateControllers.fs @@ -51,6 +51,12 @@ type UpdateArrayIntController() = type UpdateArrayEnumController() = inherit UpdateController(Array.rev) +type UpdatePriorityController() = + inherit UpdateController(id) + +type UpdateArrayPriorityController() = + inherit UpdateController(Array.rev) + type UpdateArrayGuidController() = inherit UpdateController(Array.rev) From 672a8f621e43306b8c21785218c4a708c0483f48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:41:46 +0000 Subject: [PATCH 5/9] fix: address inline review comments on enum generation Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/2c40b117-cd9d-4369-a5b6-1b47c0472d75 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../DefinitionCompiler.fs | 26 +++++++++++++++---- .../Schema.TypeMappingTests.fs | 23 ++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index dd33c22c..e6152a7e 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -526,10 +526,18 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b // compile-time type safety instead of raw string/int values. let isStringEnum = resolvedType = Some JsonSchemaType.String + // Choose int64 when the schema explicitly declares format: int64; + // otherwise default to int32 (covers string enums and unformatted integer enums). + let underlyingIntType = + if not isStringEnum && schemaObj.Format = "int64" then + typeof + else + typeof + let enumTy = ProvidedTypeDefinition(tyName, Some typeof, isErased = false) - enumTy.SetEnumUnderlyingType typeof + enumTy.SetEnumUnderlyingType underlyingIntType // String enums are serialized via JsonStringEnumConverter, which reads the // [JsonPropertyName] attribute on each member to get the wire value. @@ -538,7 +546,7 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b <| RuntimeHelpers.getJsonStringEnumConverterAttribute() let nameGen = UniqueNameGenerator() - let mutable intValue = 0 + let mutable intValue = 0L for node in schemaObj.Enum do let rawValueOpt = @@ -568,11 +576,19 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b let literalValue: obj = if isStringEnum then - intValue :> obj + // String enums always use int32 ordinals as literal values regardless of format. + // The actual wire value is stored in [JsonPropertyName], not in the literal. + (int32 intValue) :> obj + elif underlyingIntType = typeof then + match Int64.TryParse originalStr with + | true, v -> v :> obj + | false, _ -> + failwithf "Invalid int64 enum value '%s' for enum '%s'. Expected a 64-bit integer literal." originalStr tyName else match Int32.TryParse originalStr with | true, v -> v :> obj - | false, _ -> intValue :> obj + | false, _ -> + failwithf "Invalid integer enum value '%s' for enum '%s'. Expected a 32-bit integer literal." originalStr tyName let field = ProvidedField.Literal(memberName, enumTy, literalValue) @@ -584,7 +600,7 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b <| RuntimeHelpers.getPropertyNameAttribute originalStr enumTy.AddMember field - intValue <- intValue + 1 + intValue <- intValue + 1L registerNew(tyName, enumTy :> Type) enumTy :> Type diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index ca66f1b2..c8668b3a 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -643,6 +643,29 @@ let ``named integer enum has correct integer values``() = values |> shouldEqual [| 10; 20; 30 |] +[] +let ``named integer enum with int64 format has int64 underlying type``() = + let schema = + enumTestSchema + """ type: integer + format: int64 + enum: + - 1000000000000 + - 2000000000000""" + + let types = compileV3Schema schema false + let statusTy = types |> List.find(fun t -> t.Name = "Status") + statusTy.IsEnum |> shouldEqual true + Enum.GetUnderlyingType statusTy |> shouldEqual typeof + + let values = + statusTy.GetFields() + |> Array.filter(fun f -> f.IsLiteral) + |> Array.map(fun f -> f.GetRawConstantValue() :?> int64) + |> Array.sort + + values |> shouldEqual [| 1000000000000L; 2000000000000L |] + [] let ``optional named string enum property maps to Option``() = let schema = From c04afbc3d06317a9ff017deb1dc059b971116f3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:24:50 +0000 Subject: [PATCH 6/9] fix: remove redundant registerNew call in named-enum branch Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/dacfc1ea-568a-4d06-9bb6-5ac6fe02eda0 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/DefinitionCompiler.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index e6152a7e..e0297fb8 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -602,7 +602,6 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b enumTy.AddMember field intValue <- intValue + 1L - registerNew(tyName, enumTy :> Type) enumTy :> Type | _ -> ns.MarkTypeAsNameAlias tyName From 111b134057f6e3256f72a238a2a4c411329b63a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:45:05 +0000 Subject: [PATCH 7/9] fix: use JsonStringEnumMemberName instead of JsonPropertyName for enum members; add enum provider tests for all three enum kinds Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/8a5b2b75-2311-4b89-9632-18e61a74ec39 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../DefinitionCompiler.fs | 22 +++-- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 91 +++++++++++++++---- .../Schemas/enum-types.yaml | 56 ++++++++++++ .../Swagger.EnumTypes.Tests.fs | 73 +++++++++++++++ .../SwaggerProvider.ProviderTests.fsproj | 1 + .../RuntimeHelpersTests.fs | 24 ++++- .../Schema.TypeMappingTests.fs | 13 ++- 7 files changed, 249 insertions(+), 31 deletions(-) create mode 100644 tests/SwaggerProvider.ProviderTests/Schemas/enum-types.yaml create mode 100644 tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index e0297fb8..dff29207 100644 --- a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs +++ b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs @@ -539,8 +539,10 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b enumTy.SetEnumUnderlyingType underlyingIntType - // String enums are serialized via JsonStringEnumConverter, which reads the - // [JsonPropertyName] attribute on each member to get the wire value. + // String enums need [JsonConverter(typeof)] on the type + // so System.Text.Json serialises them as strings rather than integers. + // Per-member [JsonStringEnumMemberName] attributes (added below) let it honour + // the exact OpenAPI wire value for members whose .NET name was sanitised. if isStringEnum then enumTy.AddCustomAttribute <| RuntimeHelpers.getJsonStringEnumConverterAttribute() @@ -576,8 +578,8 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b let literalValue: obj = if isStringEnum then - // String enums always use int32 ordinals as literal values regardless of format. - // The actual wire value is stored in [JsonPropertyName], not in the literal. + // String enums always use int32 ordinals as literal values. + // The actual wire value is stored in [JsonStringEnumMemberName]. (int32 intValue) :> obj elif underlyingIntType = typeof then match Int64.TryParse originalStr with @@ -592,12 +594,14 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b let field = ProvidedField.Literal(memberName, enumTy, literalValue) - // Always add [JsonPropertyName] to string enum members so that - // serialization uses the exact original OpenAPI value regardless of - // how the member name was sanitized. + // Apply [JsonStringEnumMemberName] so System.Text.Json (9.0+) uses + // the exact original OpenAPI wire value when serialising this member. + // On older runtimes where the attribute is unavailable, the .NET + // member name is used as the wire value (best-effort). if isStringEnum then - field.AddCustomAttribute - <| RuntimeHelpers.getPropertyNameAttribute originalStr + match RuntimeHelpers.getEnumMemberNameAttribute originalStr with + | Some attr -> field.AddCustomAttribute attr + | None -> () enumTy.AddMember field intValue <- intValue + 1L diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 1dcc714c..c3faf1dd 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -121,9 +121,51 @@ module RuntimeHelpers = let private optionValueFactory = System.Func(fun t -> t.GetProperty("Value")) + // Reflective lookup for JsonStringEnumMemberNameAttribute (available in System.Text.Json 9.0+). + // On older runtimes (e.g. netstandard2.0 with STJ 8.x) this will be null and + // enum members are serialised using their .NET identifier name by default. + let private jsonStringEnumMemberNameType = + Type.GetType("System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute, System.Text.Json") + + let private jsonStringEnumMemberNameCtor = + if isNull jsonStringEnumMemberNameType then + null + else + jsonStringEnumMemberNameType.GetConstructor([| typeof |]) + + let private jsonStringEnumMemberNameProp = + if isNull jsonStringEnumMemberNameType then + null + else + jsonStringEnumMemberNameType.GetProperty("Name") + + /// Returns the OpenAPI wire value for a string enum field. + /// Prefers [JsonStringEnumMemberName] (STJ 9+), then [JsonPropertyName] (legacy fallback), + /// and finally the .NET field name when neither attribute is present. + let private getStringEnumMemberWireName(f: Reflection.FieldInfo) : string = + // Prefer JsonStringEnumMemberNameAttribute (the correct STJ mechanism for enum member naming). + if not(isNull jsonStringEnumMemberNameType) then + let attr = Attribute.GetCustomAttribute(f, jsonStringEnumMemberNameType) + + if not(isNull attr) then + jsonStringEnumMemberNameProp.GetValue(attr) :?> string + else + // Legacy fallback: attributes from type providers built before the switch to + // JsonStringEnumMemberName still carry [JsonPropertyName]. + let propAttr = + Attribute.GetCustomAttribute(f, typeof) :?> JsonPropertyNameAttribute + + if isNull propAttr then f.Name else propAttr.Name + else + let propAttr = + Attribute.GetCustomAttribute(f, typeof) :?> JsonPropertyNameAttribute + + if isNull propAttr then f.Name else propAttr.Name + /// Builds an (obj -> string) serializer for CLI enum types. - /// For string enums (annotated with JsonStringEnumConverter): returns the JsonPropertyName - /// value for each member, falling back to the member name. + /// For string enums (annotated with JsonStringEnumConverter): returns the + /// JsonStringEnumMemberName / JsonPropertyName wire value for each member, + /// falling back to the field name. /// For integer enums: returns the underlying integer value as a string. let private buildEnumSerializer(ty: Type) : obj -> string = let jsonConverterAttr = @@ -134,20 +176,18 @@ module RuntimeHelpers = && typeof.IsAssignableFrom(jsonConverterAttr.ConverterType) if isStringEnum then - let lookup = - ty.GetFields(Reflection.BindingFlags.Public ||| Reflection.BindingFlags.Static) - |> Array.choose(fun f -> - if f.IsLiteral then - let v = Convert.ToInt32(f.GetRawConstantValue()) - - let jsonPropAttr = - Attribute.GetCustomAttribute(f, typeof) :?> JsonPropertyNameAttribute - - let name = if isNull jsonPropAttr then f.Name else jsonPropAttr.Name - Some(v, name) - else - None) - |> dict + // Use Dictionary + TryAdd instead of |> dict to safely handle alias values + // (two enum members with the same underlying integer), which would throw in dict. + let lookup = Collections.Generic.Dictionary() + + ty.GetFields(Reflection.BindingFlags.Public ||| Reflection.BindingFlags.Static) + |> Array.iter(fun f -> + if f.IsLiteral then + let v = Convert.ToInt32(f.GetRawConstantValue()) + let name = getStringEnumMemberWireName f + // Skip alias values (same integer, different name) to prevent key collisions. + if not(lookup.ContainsKey v) then + lookup.Add(v, name)) fun (o: obj) -> let intValue = Convert.ToInt32 o @@ -348,6 +388,25 @@ module RuntimeHelpers = member _.NamedArguments = [||] :> Collections.Generic.IList<_> } + /// Builds a CustomAttributeData representing [JsonStringEnumMemberName(name)]. + /// Apply this to individual string-enum members so System.Text.Json (9.0+) honours + /// the exact OpenAPI wire value regardless of the sanitised .NET member name. + /// Returns None on runtimes where JsonStringEnumMemberNameAttribute is not available + /// (System.Text.Json < 9.0 / netstandard2.0 with STJ 8.x). + let getEnumMemberNameAttribute(name: string) = + if isNull jsonStringEnumMemberNameCtor then + None + else + Some( + { new Reflection.CustomAttributeData() with + member _.Constructor = jsonStringEnumMemberNameCtor + + member _.ConstructorArguments = + [| Reflection.CustomAttributeTypedArgument(typeof, name) |] :> Collections.Generic.IList<_> + + member _.NamedArguments = [||] :> Collections.Generic.IList<_> } + ) + let toStringContent(valueStr: string) = new StringContent(valueStr, Text.Encoding.UTF8, "application/json") diff --git a/tests/SwaggerProvider.ProviderTests/Schemas/enum-types.yaml b/tests/SwaggerProvider.ProviderTests/Schemas/enum-types.yaml new file mode 100644 index 00000000..ce5f0bc6 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/Schemas/enum-types.yaml @@ -0,0 +1,56 @@ +openapi: "3.0.0" +info: + title: Enum Types Test API + version: "1.0.0" +paths: + /string-status: + get: + operationId: getStringStatus + responses: + "200": + description: Returns a string enum value + content: + application/json: + schema: + $ref: "#/components/schemas/StringStatus" + /int-status: + get: + operationId: getIntStatus + responses: + "200": + description: Returns an integer enum value + content: + application/json: + schema: + $ref: "#/components/schemas/IntStatus" + /large-code: + get: + operationId: getLargeCode + responses: + "200": + description: Returns an int64 enum value + content: + application/json: + schema: + $ref: "#/components/schemas/LargeCode" +components: + schemas: + StringStatus: + type: string + enum: + - active + - in-active + - pending + IntStatus: + type: integer + enum: + - 200 + - 404 + - 500 + LargeCode: + type: integer + format: int64 + enum: + - 1 + - 2 + - 3 diff --git a/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs b/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs new file mode 100644 index 00000000..5957e108 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs @@ -0,0 +1,73 @@ +module Swagger.EnumTypes.Tests + +open Xunit +open FsUnitTyped +open SwaggerProvider + +[] +let Schema = __SOURCE_DIRECTORY__ + "/Schemas/enum-types.yaml" + +type EnumApi = OpenApiClientProvider + +// Compile-time verification: member names are accessible. +// The sanitised member names must match what DefinitionCompiler produces. +let _: EnumApi.StringStatus = EnumApi.StringStatus.Active +let _: EnumApi.StringStatus = EnumApi.StringStatus.InActive +let _: EnumApi.StringStatus = EnumApi.StringStatus.Pending +let _: EnumApi.IntStatus = EnumApi.IntStatus.V200 +let _: EnumApi.IntStatus = EnumApi.IntStatus.V404 +let _: EnumApi.IntStatus = EnumApi.IntStatus.V500 +let _: EnumApi.LargeCode = EnumApi.LargeCode.V1 +let _: EnumApi.LargeCode = EnumApi.LargeCode.V2 +let _: EnumApi.LargeCode = EnumApi.LargeCode.V3 + +// ── String enum ──────────────────────────────────────────────────────────── + +[] +let ``string enum is a CLI enum type``() = + typeof.IsEnum |> shouldEqual true + +[] +let ``string enum has int32 underlying type``() = + System.Enum.GetUnderlyingType typeof + |> shouldEqual typeof + +[] +let ``string enum member names are sanitised from OpenAPI values``() = + System.Enum.GetNames typeof + |> Array.sort + |> shouldEqual [| "Active"; "InActive"; "Pending" |] + +// ── Integer (int32) enum ─────────────────────────────────────────────────── + +[] +let ``int32 enum is a CLI enum type``() = + typeof.IsEnum |> shouldEqual true + +[] +let ``int32 enum has int32 underlying type``() = + System.Enum.GetUnderlyingType typeof + |> shouldEqual typeof + +[] +let ``int32 enum has correct integer values``() = + int EnumApi.IntStatus.V200 |> shouldEqual 200 + int EnumApi.IntStatus.V404 |> shouldEqual 404 + int EnumApi.IntStatus.V500 |> shouldEqual 500 + +// ── Integer (int64) enum ─────────────────────────────────────────────────── + +[] +let ``int64 enum is a CLI enum type``() = + typeof.IsEnum |> shouldEqual true + +[] +let ``int64 enum has int64 underlying type``() = + System.Enum.GetUnderlyingType typeof + |> shouldEqual typeof + +[] +let ``int64 enum has correct integer values``() = + int64 EnumApi.LargeCode.V1 |> shouldEqual 1L + int64 EnumApi.LargeCode.V2 |> shouldEqual 2L + int64 EnumApi.LargeCode.V3 |> shouldEqual 3L diff --git a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj index 8c4f9bfb..a79af7dc 100644 --- a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj +++ b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj @@ -27,6 +27,7 @@ + APIs.guru.fs diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 9ee71c48..5a1bbb85 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -122,10 +122,18 @@ module ToParamTests = // toParam must serialize string enums to their original OpenAPI wire values and integer // enums to their underlying integer representation. module EnumToParamTests = - // String enum: decorated with [JsonConverter(typeof)] and - // each member has [JsonPropertyName("wire-value")] to handle sanitized names. + // String enum with the new JsonStringEnumMemberName attribute (STJ 9+). + // This is what the type provider now emits for sanitised member names. [)>] type StringStatus = + | [] Active = 0 + | [] Inactive = 1 + | [] InProgress = 2 + + // Backward-compat: string enum using the legacy [JsonPropertyName] attribute. + // buildEnumSerializer must still work for enums generated by older type-provider builds. + [)>] + type LegacyStringStatus = | [] Active = 0 | [] Inactive = 1 | [] InProgress = 2 @@ -176,6 +184,18 @@ module EnumToParamTests = let result = toParam(box(Some IntStatus.Done)) result |> shouldEqual "3" + // Backward-compat tests: enums generated by older type-provider builds carry + // [JsonPropertyName] instead of [JsonStringEnumMemberName]; they must still work. + [] + let ``toParam serializes legacy JsonPropertyName string enum (backward compat)``() = + let result = toParam(box LegacyStringStatus.InProgress) + result |> shouldEqual "in-progress" + + [] + let ``toParam serializes legacy JsonPropertyName inactive (backward compat)``() = + let result = toParam(box LegacyStringStatus.Inactive) + result |> shouldEqual "inactive" + module ToQueryParamsTests = diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index c8668b3a..d516ba40 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -585,7 +585,7 @@ let ``named string enum has JsonStringEnumConverter attribute``() = |> shouldEqual true [] -let ``named string enum members have JsonPropertyName attributes for wire values``() = +let ``named string enum members have JsonStringEnumMemberName attributes for wire values``() = let schema = enumTestSchema """ type: string @@ -596,17 +596,22 @@ let ``named string enum members have JsonPropertyName attributes for wire values let types = compileV3Schema schema false let statusTy = types |> List.find(fun t -> t.Name = "Status") - let jsonNames = + let memberNameAttrType = + Type.GetType("System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute, System.Text.Json") + + let wireNames = statusTy.GetFields() |> Array.filter(fun f -> f.IsLiteral) |> Array.collect(fun f -> f.GetCustomAttributesData() - |> Seq.filter(fun a -> a.Constructor.DeclaringType = typeof) + |> Seq.filter(fun a -> + not(isNull memberNameAttrType) + && a.Constructor.DeclaringType = memberNameAttrType) |> Seq.map(fun a -> a.ConstructorArguments.[0].Value :?> string) |> Seq.toArray) |> Array.sort - jsonNames |> shouldEqual [| "active"; "in-active" |] + wireNames |> shouldEqual [| "active"; "in-active" |] [] let ``named integer enum schema compiles to a CLI enum type``() = From 6d2ec944ce9b0fd3e0daf60cf0020b3f5e87e0b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:47:34 +0000 Subject: [PATCH 8/9] review: use typeof directly in test; move compile-time checks into explicit test method Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/8a5b2b75-2311-4b89-9632-18e61a74ec39 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../Swagger.EnumTypes.Tests.fs | 22 +++++++++---------- .../Schema.TypeMappingTests.fs | 7 +----- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs b/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs index 5957e108..243e89f0 100644 --- a/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs @@ -9,18 +9,6 @@ let Schema = __SOURCE_DIRECTORY__ + "/Schemas/enum-types.yaml" type EnumApi = OpenApiClientProvider -// Compile-time verification: member names are accessible. -// The sanitised member names must match what DefinitionCompiler produces. -let _: EnumApi.StringStatus = EnumApi.StringStatus.Active -let _: EnumApi.StringStatus = EnumApi.StringStatus.InActive -let _: EnumApi.StringStatus = EnumApi.StringStatus.Pending -let _: EnumApi.IntStatus = EnumApi.IntStatus.V200 -let _: EnumApi.IntStatus = EnumApi.IntStatus.V404 -let _: EnumApi.IntStatus = EnumApi.IntStatus.V500 -let _: EnumApi.LargeCode = EnumApi.LargeCode.V1 -let _: EnumApi.LargeCode = EnumApi.LargeCode.V2 -let _: EnumApi.LargeCode = EnumApi.LargeCode.V3 - // ── String enum ──────────────────────────────────────────────────────────── [] @@ -38,6 +26,16 @@ let ``string enum member names are sanitised from OpenAPI values``() = |> Array.sort |> shouldEqual [| "Active"; "InActive"; "Pending" |] +// Compile-time assertion: sanitised member names are accessible as enum cases. +[] +let ``string enum members are accessible as typed enum cases``() = + let active: EnumApi.StringStatus = EnumApi.StringStatus.Active + let inActive: EnumApi.StringStatus = EnumApi.StringStatus.InActive + let pending: EnumApi.StringStatus = EnumApi.StringStatus.Pending + active |> shouldEqual EnumApi.StringStatus.Active + inActive |> shouldEqual EnumApi.StringStatus.InActive + pending |> shouldEqual EnumApi.StringStatus.Pending + // ── Integer (int32) enum ─────────────────────────────────────────────────── [] diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index d516ba40..cf84e9ed 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -596,17 +596,12 @@ let ``named string enum members have JsonStringEnumMemberName attributes for wir let types = compileV3Schema schema false let statusTy = types |> List.find(fun t -> t.Name = "Status") - let memberNameAttrType = - Type.GetType("System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute, System.Text.Json") - let wireNames = statusTy.GetFields() |> Array.filter(fun f -> f.IsLiteral) |> Array.collect(fun f -> f.GetCustomAttributesData() - |> Seq.filter(fun a -> - not(isNull memberNameAttrType) - && a.Constructor.DeclaringType = memberNameAttrType) + |> Seq.filter(fun a -> a.Constructor.DeclaringType = typeof) |> Seq.map(fun a -> a.ConstructorArguments.[0].Value :?> string) |> Seq.toArray) |> Array.sort From 317dfc9f96ca92f3e96cbe4d6e11875d4469a89c Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Tue, 28 Apr 2026 21:06:06 +0200 Subject: [PATCH 9/9] Update src/SwaggerProvider.Runtime/RuntimeHelpers.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index c3faf1dd..7a16262c 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -176,7 +176,7 @@ module RuntimeHelpers = && typeof.IsAssignableFrom(jsonConverterAttr.ConverterType) if isStringEnum then - // Use Dictionary + TryAdd instead of |> dict to safely handle alias values + // Use Dictionary with ContainsKey + Add instead of |> dict to safely handle alias values // (two enum members with the same underlying integer), which would throw in dict. let lookup = Collections.Generic.Dictionary()