diff --git a/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs b/src/SwaggerProvider.DesignTime/DefinitionCompiler.fs index 31ac440a..dff29207 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,99 @@ 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 + + // 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 underlyingIntType + + // 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() + + let nameGen = UniqueNameGenerator() + let mutable intValue = 0L + + 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 + // 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 + | 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, _ -> + failwithf "Invalid integer enum value '%s' for enum '%s'. Expected a 32-bit integer literal." originalStr tyName + + let field = ProvidedField.Literal(memberName, enumTy, literalValue) + + // 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 + match RuntimeHelpers.getEnumMemberNameAttribute originalStr with + | Some attr -> field.AddCustomAttribute attr + | None -> () + + enumTy.AddMember field + intValue <- intValue + 1L + + enumTy :> Type | _ -> ns.MarkTypeAsNameAlias tyName diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 1febdbec..7a16262c 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -121,6 +121,91 @@ 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 + /// 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 = + Attribute.GetCustomAttribute(ty, typeof) :?> JsonConverterAttribute + + let isStringEnum = + not(isNull jsonConverterAttr) + && typeof.IsAssignableFrom(jsonConverterAttr.ConverterType) + + if isStringEnum then + // 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() + + 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 + + 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") @@ -151,6 +236,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() @@ -186,7 +276,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 @@ -283,6 +373,40 @@ 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<_> } + + /// 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..243e89f0 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs @@ -0,0 +1,71 @@ +module Swagger.EnumTypes.Tests + +open Xunit +open FsUnitTyped +open SwaggerProvider + +[] +let Schema = __SOURCE_DIRECTORY__ + "/Schemas/enum-types.yaml" + +type EnumApi = OpenApiClientProvider + +// ── 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" |] + +// 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 ─────────────────────────────────────────────────── + +[] +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 e37df186..a79af7dc 100644 --- a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj +++ b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj @@ -26,6 +26,8 @@ + + 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/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``() = diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index edde0397..5a1bbb85 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -118,6 +118,85 @@ 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 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 + + // 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" + + // 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 = let private stubClient = diff --git a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs index e3c3948f..cf84e9ed 100644 --- a/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.TypeMappingTests.fs @@ -499,3 +499,226 @@ 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 JsonStringEnumMemberName 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 wireNames = + 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 + + wireNames |> 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 ``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 = + """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 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)