Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/SwaggerProvider.DesignTime/DefinitionCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<int64>
else
typeof<int32>

let enumTy =
ProvidedTypeDefinition(tyName, Some typeof<System.Enum>, isErased = false)

enumTy.SetEnumUnderlyingType underlyingIntType

Comment thread
sergey-tihon marked this conversation as resolved.
// String enums need [JsonConverter(typeof<JsonStringEnumConverter>)] 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<string>()) 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<int64> 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
Comment thread
sergey-tihon marked this conversation as resolved.
| None -> ()

enumTy.AddMember field
intValue <- intValue + 1L

enumTy :> Type
| _ ->
ns.MarkTypeAsNameAlias tyName

Expand Down
126 changes: 125 additions & 1 deletion src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,91 @@ module RuntimeHelpers =
let private optionValueFactory =
System.Func<Type, Reflection.PropertyInfo>(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<string> |])

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>) :?> JsonPropertyNameAttribute

if isNull propAttr then f.Name else propAttr.Name
else
let propAttr =
Attribute.GetCustomAttribute(f, typeof<JsonPropertyNameAttribute>) :?> 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>) :?> JsonConverterAttribute

let isStringEnum =
not(isNull jsonConverterAttr)
&& typeof<JsonStringEnumConverter>.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<int, string>()

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<Type, obj -> string>()

let private enumSerializerFactory =
System.Func<Type, obj -> string>(buildEnumSerializer)

let rec toParam(obj: obj) =
match obj with
| :? DateTime as dt -> dt.ToString("O")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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<obj>
Expand Down Expand Up @@ -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<JsonConverterAttribute>.GetConstructor [| typeof<Type> |]

/// Builds a CustomAttributeData representing [JsonConverter(typeof<JsonStringEnumConverter>)].
/// 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<Type>, typeof<JsonStringEnumConverter>) |] :> 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<string>, name) |] :> Collections.Generic.IList<_>

member _.NamedArguments = [||] :> Collections.Generic.IList<_> }
)

let toStringContent(valueStr: string) =
new StringContent(valueStr, Text.Encoding.UTF8, "application/json")

Expand Down
56 changes: 56 additions & 0 deletions tests/SwaggerProvider.ProviderTests/Schemas/enum-types.yaml
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions tests/SwaggerProvider.ProviderTests/Swagger.EnumTypes.Tests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
module Swagger.EnumTypes.Tests

open Xunit
open FsUnitTyped
open SwaggerProvider

[<Literal>]
let Schema = __SOURCE_DIRECTORY__ + "/Schemas/enum-types.yaml"

type EnumApi = OpenApiClientProvider<Schema, SsrfProtection=false>

// ── String enum ────────────────────────────────────────────────────────────

[<Fact>]
let ``string enum is a CLI enum type``() =
typeof<EnumApi.StringStatus>.IsEnum |> shouldEqual true

[<Fact>]
let ``string enum has int32 underlying type``() =
System.Enum.GetUnderlyingType typeof<EnumApi.StringStatus>
|> shouldEqual typeof<int32>

[<Fact>]
let ``string enum member names are sanitised from OpenAPI values``() =
System.Enum.GetNames typeof<EnumApi.StringStatus>
|> Array.sort
|> shouldEqual [| "Active"; "InActive"; "Pending" |]

// Compile-time assertion: sanitised member names are accessible as enum cases.
[<Fact>]
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 ───────────────────────────────────────────────────

[<Fact>]
let ``int32 enum is a CLI enum type``() =
typeof<EnumApi.IntStatus>.IsEnum |> shouldEqual true

[<Fact>]
let ``int32 enum has int32 underlying type``() =
System.Enum.GetUnderlyingType typeof<EnumApi.IntStatus>
|> shouldEqual typeof<int32>

[<Fact>]
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 ───────────────────────────────────────────────────

[<Fact>]
let ``int64 enum is a CLI enum type``() =
typeof<EnumApi.LargeCode>.IsEnum |> shouldEqual true

[<Fact>]
let ``int64 enum has int64 underlying type``() =
System.Enum.GetUnderlyingType typeof<EnumApi.LargeCode>
|> shouldEqual typeof<int64>

[<Fact>]
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
Loading
Loading