Skip to content

Commit e7a3f6e

Browse files
github-actions[bot]CopilotCopilotsergey-tihonCopilot
authored
[Repo Assist] feat: generate CLI enum types for top-level named enum schemas (#409)
* 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<JsonStringEnumConverter>)] 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<EnumType> unwrapping. Closes #146 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * 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> * 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> * 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> * 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> * 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> * review: use typeof<JsonStringEnumMemberNameAttribute> 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> * Update src/SwaggerProvider.Runtime/RuntimeHelpers.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Co-authored-by: Sergey Tihon <sergey.tihon@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 973686d commit e7a3f6e

13 files changed

Lines changed: 756 additions & 11 deletions

File tree

src/SwaggerProvider.DesignTime/DefinitionCompiler.fs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ namespace SwaggerProvider.Internal.Compilers
22

33
open System
44
open System.Reflection
5+
open System.Text.Json
6+
open System.Text.Json.Nodes
57
open ProviderImplementation.ProvidedTypes
68
open UncheckedQuotations
79
open FSharp.Data.Runtime.NameUtils
@@ -512,6 +514,99 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
512514
|| resolvedType = Some(JsonSchemaType.Null ||| JsonSchemaType.Object)
513515
->
514516
compileNewObject()
517+
| _ when
518+
fromByPathCompiler
519+
&& not(isNull tyName)
520+
&& (resolvedType = Some JsonSchemaType.String
521+
|| resolvedType = Some JsonSchemaType.Integer)
522+
&& not(isNull schemaObj.Enum)
523+
&& schemaObj.Enum.Count > 0
524+
->
525+
// Top-level named enum schema: generate a CLI enum type so callers have
526+
// compile-time type safety instead of raw string/int values.
527+
let isStringEnum = resolvedType = Some JsonSchemaType.String
528+
529+
// Choose int64 when the schema explicitly declares format: int64;
530+
// otherwise default to int32 (covers string enums and unformatted integer enums).
531+
let underlyingIntType =
532+
if not isStringEnum && schemaObj.Format = "int64" then
533+
typeof<int64>
534+
else
535+
typeof<int32>
536+
537+
let enumTy =
538+
ProvidedTypeDefinition(tyName, Some typeof<System.Enum>, isErased = false)
539+
540+
enumTy.SetEnumUnderlyingType underlyingIntType
541+
542+
// String enums need [JsonConverter(typeof<JsonStringEnumConverter>)] on the type
543+
// so System.Text.Json serialises them as strings rather than integers.
544+
// Per-member [JsonStringEnumMemberName] attributes (added below) let it honour
545+
// the exact OpenAPI wire value for members whose .NET name was sanitised.
546+
if isStringEnum then
547+
enumTy.AddCustomAttribute
548+
<| RuntimeHelpers.getJsonStringEnumConverterAttribute()
549+
550+
let nameGen = UniqueNameGenerator()
551+
let mutable intValue = 0L
552+
553+
for node in schemaObj.Enum do
554+
let rawValueOpt =
555+
match node with
556+
| :? JsonValue as jv ->
557+
match jv.GetValueKind() with
558+
| JsonValueKind.String -> if isStringEnum then Some(jv.GetValue<string>()) else None
559+
| JsonValueKind.Number -> if isStringEnum then None else Some(jv.ToString())
560+
| JsonValueKind.Null -> None
561+
| _ -> None
562+
| _ when not(isNull node) -> Some(node.ToString())
563+
| _ -> None
564+
565+
match rawValueOpt with
566+
| None -> ()
567+
| Some originalStr ->
568+
// Sanitize to a valid .NET identifier.
569+
let rawName = nicePascalName originalStr
570+
571+
let sanitized =
572+
if String.IsNullOrEmpty rawName || Char.IsDigit rawName.[0] then
573+
"V" + rawName
574+
else
575+
rawName
576+
577+
let memberName = nameGen.MakeUnique sanitized
578+
579+
let literalValue: obj =
580+
if isStringEnum then
581+
// String enums always use int32 ordinals as literal values.
582+
// The actual wire value is stored in [JsonStringEnumMemberName].
583+
(int32 intValue) :> obj
584+
elif underlyingIntType = typeof<int64> then
585+
match Int64.TryParse originalStr with
586+
| true, v -> v :> obj
587+
| false, _ ->
588+
failwithf "Invalid int64 enum value '%s' for enum '%s'. Expected a 64-bit integer literal." originalStr tyName
589+
else
590+
match Int32.TryParse originalStr with
591+
| true, v -> v :> obj
592+
| false, _ ->
593+
failwithf "Invalid integer enum value '%s' for enum '%s'. Expected a 32-bit integer literal." originalStr tyName
594+
595+
let field = ProvidedField.Literal(memberName, enumTy, literalValue)
596+
597+
// Apply [JsonStringEnumMemberName] so System.Text.Json (9.0+) uses
598+
// the exact original OpenAPI wire value when serialising this member.
599+
// On older runtimes where the attribute is unavailable, the .NET
600+
// member name is used as the wire value (best-effort).
601+
if isStringEnum then
602+
match RuntimeHelpers.getEnumMemberNameAttribute originalStr with
603+
| Some attr -> field.AddCustomAttribute attr
604+
| None -> ()
605+
606+
enumTy.AddMember field
607+
intValue <- intValue + 1L
608+
609+
enumTy :> Type
515610
| _ ->
516611
ns.MarkTypeAsNameAlias tyName
517612

src/SwaggerProvider.Runtime/RuntimeHelpers.fs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,91 @@ module RuntimeHelpers =
121121
let private optionValueFactory =
122122
System.Func<Type, Reflection.PropertyInfo>(fun t -> t.GetProperty("Value"))
123123

124+
// Reflective lookup for JsonStringEnumMemberNameAttribute (available in System.Text.Json 9.0+).
125+
// On older runtimes (e.g. netstandard2.0 with STJ 8.x) this will be null and
126+
// enum members are serialised using their .NET identifier name by default.
127+
let private jsonStringEnumMemberNameType =
128+
Type.GetType("System.Text.Json.Serialization.JsonStringEnumMemberNameAttribute, System.Text.Json")
129+
130+
let private jsonStringEnumMemberNameCtor =
131+
if isNull jsonStringEnumMemberNameType then
132+
null
133+
else
134+
jsonStringEnumMemberNameType.GetConstructor([| typeof<string> |])
135+
136+
let private jsonStringEnumMemberNameProp =
137+
if isNull jsonStringEnumMemberNameType then
138+
null
139+
else
140+
jsonStringEnumMemberNameType.GetProperty("Name")
141+
142+
/// Returns the OpenAPI wire value for a string enum field.
143+
/// Prefers [JsonStringEnumMemberName] (STJ 9+), then [JsonPropertyName] (legacy fallback),
144+
/// and finally the .NET field name when neither attribute is present.
145+
let private getStringEnumMemberWireName(f: Reflection.FieldInfo) : string =
146+
// Prefer JsonStringEnumMemberNameAttribute (the correct STJ mechanism for enum member naming).
147+
if not(isNull jsonStringEnumMemberNameType) then
148+
let attr = Attribute.GetCustomAttribute(f, jsonStringEnumMemberNameType)
149+
150+
if not(isNull attr) then
151+
jsonStringEnumMemberNameProp.GetValue(attr) :?> string
152+
else
153+
// Legacy fallback: attributes from type providers built before the switch to
154+
// JsonStringEnumMemberName still carry [JsonPropertyName].
155+
let propAttr =
156+
Attribute.GetCustomAttribute(f, typeof<JsonPropertyNameAttribute>) :?> JsonPropertyNameAttribute
157+
158+
if isNull propAttr then f.Name else propAttr.Name
159+
else
160+
let propAttr =
161+
Attribute.GetCustomAttribute(f, typeof<JsonPropertyNameAttribute>) :?> JsonPropertyNameAttribute
162+
163+
if isNull propAttr then f.Name else propAttr.Name
164+
165+
/// Builds an (obj -> string) serializer for CLI enum types.
166+
/// For string enums (annotated with JsonStringEnumConverter): returns the
167+
/// JsonStringEnumMemberName / JsonPropertyName wire value for each member,
168+
/// falling back to the field name.
169+
/// For integer enums: returns the underlying integer value as a string.
170+
let private buildEnumSerializer(ty: Type) : obj -> string =
171+
let jsonConverterAttr =
172+
Attribute.GetCustomAttribute(ty, typeof<JsonConverterAttribute>) :?> JsonConverterAttribute
173+
174+
let isStringEnum =
175+
not(isNull jsonConverterAttr)
176+
&& typeof<JsonStringEnumConverter>.IsAssignableFrom(jsonConverterAttr.ConverterType)
177+
178+
if isStringEnum then
179+
// Use Dictionary with ContainsKey + Add instead of |> dict to safely handle alias values
180+
// (two enum members with the same underlying integer), which would throw in dict.
181+
let lookup = Collections.Generic.Dictionary<int, string>()
182+
183+
ty.GetFields(Reflection.BindingFlags.Public ||| Reflection.BindingFlags.Static)
184+
|> Array.iter(fun f ->
185+
if f.IsLiteral then
186+
let v = Convert.ToInt32(f.GetRawConstantValue())
187+
let name = getStringEnumMemberWireName f
188+
// Skip alias values (same integer, different name) to prevent key collisions.
189+
if not(lookup.ContainsKey v) then
190+
lookup.Add(v, name))
191+
192+
fun (o: obj) ->
193+
let intValue = Convert.ToInt32 o
194+
195+
match lookup.TryGetValue intValue with
196+
| true, s -> s
197+
| false, _ -> o.ToString()
198+
else
199+
let underlyingType = Enum.GetUnderlyingType ty
200+
fun (o: obj) -> Convert.ChangeType(o, underlyingType).ToString()
201+
202+
// Cache of enum type -> (obj -> string) serializer, built lazily per type.
203+
let private enumSerializerCache =
204+
Collections.Concurrent.ConcurrentDictionary<Type, obj -> string>()
205+
206+
let private enumSerializerFactory =
207+
System.Func<Type, obj -> string>(buildEnumSerializer)
208+
124209
let rec toParam(obj: obj) =
125210
match obj with
126211
| :? DateTime as dt -> dt.ToString("O")
@@ -151,6 +236,11 @@ module RuntimeHelpers =
151236
toParam(valueProp.GetValue(obj))
152237
else
153238
null
239+
elif ty.IsEnum then
240+
// CLI enum type: use the cached serializer so string enums produce their
241+
// original OpenAPI string value and integer enums produce the integer.
242+
let serializer = enumSerializerCache.GetOrAdd(ty, enumSerializerFactory)
243+
serializer obj
154244
else
155245
obj.ToString()
156246

@@ -186,7 +276,7 @@ module RuntimeHelpers =
186276
| :? Array as xs when
187277
xs.GetType().GetElementType()
188278
|> Option.ofObj
189-
|> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t)
279+
|> Option.exists(fun t -> isDateOnlyLikeType t || isTimeOnlyLikeType t || t.IsEnum)
190280
->
191281
xs
192282
|> Seq.cast<obj>
@@ -283,6 +373,40 @@ module RuntimeHelpers =
283373

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

376+
// Cached constructor for JsonConverterAttribute (used to apply JsonStringEnumConverter to generated enum types).
377+
let private jsonConverterCtor =
378+
typeof<JsonConverterAttribute>.GetConstructor [| typeof<Type> |]
379+
380+
/// Builds a CustomAttributeData representing [JsonConverter(typeof<JsonStringEnumConverter>)].
381+
/// Apply this to generated CLI enum types so System.Text.Json serialises them as strings.
382+
let getJsonStringEnumConverterAttribute() =
383+
{ new Reflection.CustomAttributeData() with
384+
member _.Constructor = jsonConverterCtor
385+
386+
member _.ConstructorArguments =
387+
[| Reflection.CustomAttributeTypedArgument(typeof<Type>, typeof<JsonStringEnumConverter>) |] :> Collections.Generic.IList<_>
388+
389+
member _.NamedArguments = [||] :> Collections.Generic.IList<_> }
390+
391+
/// Builds a CustomAttributeData representing [JsonStringEnumMemberName(name)].
392+
/// Apply this to individual string-enum members so System.Text.Json (9.0+) honours
393+
/// the exact OpenAPI wire value regardless of the sanitised .NET member name.
394+
/// Returns None on runtimes where JsonStringEnumMemberNameAttribute is not available
395+
/// (System.Text.Json < 9.0 / netstandard2.0 with STJ 8.x).
396+
let getEnumMemberNameAttribute(name: string) =
397+
if isNull jsonStringEnumMemberNameCtor then
398+
None
399+
else
400+
Some(
401+
{ new Reflection.CustomAttributeData() with
402+
member _.Constructor = jsonStringEnumMemberNameCtor
403+
404+
member _.ConstructorArguments =
405+
[| Reflection.CustomAttributeTypedArgument(typeof<string>, name) |] :> Collections.Generic.IList<_>
406+
407+
member _.NamedArguments = [||] :> Collections.Generic.IList<_> }
408+
)
409+
286410
let toStringContent(valueStr: string) =
287411
new StringContent(valueStr, Text.Encoding.UTF8, "application/json")
288412

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Enum Types Test API
4+
version: "1.0.0"
5+
paths:
6+
/string-status:
7+
get:
8+
operationId: getStringStatus
9+
responses:
10+
"200":
11+
description: Returns a string enum value
12+
content:
13+
application/json:
14+
schema:
15+
$ref: "#/components/schemas/StringStatus"
16+
/int-status:
17+
get:
18+
operationId: getIntStatus
19+
responses:
20+
"200":
21+
description: Returns an integer enum value
22+
content:
23+
application/json:
24+
schema:
25+
$ref: "#/components/schemas/IntStatus"
26+
/large-code:
27+
get:
28+
operationId: getLargeCode
29+
responses:
30+
"200":
31+
description: Returns an int64 enum value
32+
content:
33+
application/json:
34+
schema:
35+
$ref: "#/components/schemas/LargeCode"
36+
components:
37+
schemas:
38+
StringStatus:
39+
type: string
40+
enum:
41+
- active
42+
- in-active
43+
- pending
44+
IntStatus:
45+
type: integer
46+
enum:
47+
- 200
48+
- 404
49+
- 500
50+
LargeCode:
51+
type: integer
52+
format: int64
53+
enum:
54+
- 1
55+
- 2
56+
- 3
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
module Swagger.EnumTypes.Tests
2+
3+
open Xunit
4+
open FsUnitTyped
5+
open SwaggerProvider
6+
7+
[<Literal>]
8+
let Schema = __SOURCE_DIRECTORY__ + "/Schemas/enum-types.yaml"
9+
10+
type EnumApi = OpenApiClientProvider<Schema, SsrfProtection=false>
11+
12+
// ── String enum ────────────────────────────────────────────────────────────
13+
14+
[<Fact>]
15+
let ``string enum is a CLI enum type``() =
16+
typeof<EnumApi.StringStatus>.IsEnum |> shouldEqual true
17+
18+
[<Fact>]
19+
let ``string enum has int32 underlying type``() =
20+
System.Enum.GetUnderlyingType typeof<EnumApi.StringStatus>
21+
|> shouldEqual typeof<int32>
22+
23+
[<Fact>]
24+
let ``string enum member names are sanitised from OpenAPI values``() =
25+
System.Enum.GetNames typeof<EnumApi.StringStatus>
26+
|> Array.sort
27+
|> shouldEqual [| "Active"; "InActive"; "Pending" |]
28+
29+
// Compile-time assertion: sanitised member names are accessible as enum cases.
30+
[<Fact>]
31+
let ``string enum members are accessible as typed enum cases``() =
32+
let active: EnumApi.StringStatus = EnumApi.StringStatus.Active
33+
let inActive: EnumApi.StringStatus = EnumApi.StringStatus.InActive
34+
let pending: EnumApi.StringStatus = EnumApi.StringStatus.Pending
35+
active |> shouldEqual EnumApi.StringStatus.Active
36+
inActive |> shouldEqual EnumApi.StringStatus.InActive
37+
pending |> shouldEqual EnumApi.StringStatus.Pending
38+
39+
// ── Integer (int32) enum ───────────────────────────────────────────────────
40+
41+
[<Fact>]
42+
let ``int32 enum is a CLI enum type``() =
43+
typeof<EnumApi.IntStatus>.IsEnum |> shouldEqual true
44+
45+
[<Fact>]
46+
let ``int32 enum has int32 underlying type``() =
47+
System.Enum.GetUnderlyingType typeof<EnumApi.IntStatus>
48+
|> shouldEqual typeof<int32>
49+
50+
[<Fact>]
51+
let ``int32 enum has correct integer values``() =
52+
int EnumApi.IntStatus.V200 |> shouldEqual 200
53+
int EnumApi.IntStatus.V404 |> shouldEqual 404
54+
int EnumApi.IntStatus.V500 |> shouldEqual 500
55+
56+
// ── Integer (int64) enum ───────────────────────────────────────────────────
57+
58+
[<Fact>]
59+
let ``int64 enum is a CLI enum type``() =
60+
typeof<EnumApi.LargeCode>.IsEnum |> shouldEqual true
61+
62+
[<Fact>]
63+
let ``int64 enum has int64 underlying type``() =
64+
System.Enum.GetUnderlyingType typeof<EnumApi.LargeCode>
65+
|> shouldEqual typeof<int64>
66+
67+
[<Fact>]
68+
let ``int64 enum has correct integer values``() =
69+
int64 EnumApi.LargeCode.V1 |> shouldEqual 1L
70+
int64 EnumApi.LargeCode.V2 |> shouldEqual 2L
71+
int64 EnumApi.LargeCode.V3 |> shouldEqual 3L

0 commit comments

Comments
 (0)