Skip to content

Commit 3c0b3a2

Browse files
committed
Added transformer for handling fsharp options
1 parent 6d69692 commit 3c0b3a2

4 files changed

Lines changed: 200 additions & 3 deletions

File tree

examples/Basic/Program.fs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ let handler2 (name: string) (age: int) : EndpointHandler =
3434
let handler3 (a: string) (b: string) (c: string) (d: int) : EndpointHandler =
3535
_.WriteText($"Hello %s{a} %s{b} %s{c} %i{d}")
3636

37-
[<CLIMutable>]
37+
3838
type MyModel = { Name: string; Age: int }
39+
[<CLIMutable>]
40+
type MyModelWithOption = { Name: string option; Age: Nullable<int> }
41+
3942
let handler4 (a: MyModel) : EndpointHandler =
4043
fun (ctx: HttpContext) -> task { return! ctx.WriteJsonChunked { a with Name = a.Name + "!" } }
4144

@@ -170,7 +173,7 @@ let endpoints = [
170173
|> addOpenApi(
171174
OpenApiConfig(
172175
requestBody =
173-
RequestBody(typeof<MyModel>, [| "multipart/form-data"; "application/x-www-form-urlencoded" |]),
176+
RequestBody(typeof<MyModelWithOption>, [| "multipart/form-data"; "application/x-www-form-urlencoded" |]),
174177
responseBodies = [ ResponseBody(typeof<MyModel>) ]
175178
)
176179
)
@@ -241,7 +244,9 @@ let configureApp (appBuilder: IApplicationBuilder) =
241244
appBuilder.UseRouting().Use(errorHandler).UseOxpecker(endpoints).Run(notFoundHandler)
242245

243246
let configureServices (services: IServiceCollection) =
244-
services.AddRouting().AddOxpecker().AddOpenApi() |> ignore
247+
services.AddRouting().AddOxpecker().AddOpenApi(fun o ->
248+
o.AddSchemaTransformer<FSharpOptionSchemaTransformer>() |> ignore
249+
) |> ignore
245250

246251

247252
[<EntryPoint>]

src/Oxpecker.OpenApi/Oxpecker.OpenApi.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<ItemGroup>
3232
<None Include="..\..\images\oxpecker-128.png" Pack="true" PackagePath="\" />
3333
<None Include="README.md" Pack="true" PackagePath="\" />
34+
<Compile Include="Transformers.fs" />
3435
<Compile Include="Configuration.fs" />
3536
<Compile Include="Routing.fs" />
3637
</ItemGroup>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
namespace Oxpecker.OpenApi
2+
3+
open System
4+
open System.Collections.Generic
5+
open System.Threading
6+
open System.Threading.Tasks
7+
open Microsoft.AspNetCore.OpenApi
8+
open Microsoft.OpenApi
9+
open FSharp.Control
10+
open type Microsoft.AspNetCore.Http.TypedResults
11+
12+
13+
// ---------- F# helpers ----------
14+
module private FSharpTypeChecks =
15+
let (|FSharpOptionKind|_|) (t: Type) =
16+
if t.IsGenericType then
17+
let gtd = t.GetGenericTypeDefinition()
18+
if gtd = typedefof<option<_>> || gtd = typedefof<ValueOption<_>> then
19+
Some (t.GetGenericArguments()[0])
20+
else None
21+
else None
22+
23+
module private SchemaCopy =
24+
25+
/// Shallowly "adopt" the public, settable surface from `src` into `dst`.
26+
/// We intentionally avoid touching internals; only copy what's publicly available.
27+
let copyTo (dst: OpenApiSchema) (src: IOpenApiSchema) =
28+
// Json-Schema identity & metadata
29+
dst.Title <- src.Title
30+
dst.Schema <- src.Schema
31+
dst.Id <- src.Id
32+
dst.Comment <- src.Comment
33+
dst.Vocabulary <-
34+
match src.Vocabulary with
35+
| null -> null
36+
| v -> Dictionary(v)
37+
dst.DynamicRef <- src.DynamicRef
38+
dst.DynamicAnchor <- src.DynamicAnchor
39+
dst.Definitions <-
40+
match src.Definitions with
41+
| null -> null
42+
| d -> Dictionary(d)
43+
// Numeric/string constraints
44+
dst.ExclusiveMaximum <- src.ExclusiveMaximum
45+
dst.ExclusiveMinimum <- src.ExclusiveMinimum
46+
dst.Maximum <- src.Maximum
47+
dst.Minimum <- src.Minimum
48+
dst.MultipleOf <- src.MultipleOf
49+
dst.MaxLength <- src.MaxLength
50+
dst.MinLength <- src.MinLength
51+
dst.Pattern <- src.Pattern
52+
53+
// Type/format & const/default
54+
dst.Type <- src.Type
55+
dst.Format <- src.Format
56+
dst.Const <- src.Const
57+
dst.Default <- src.Default
58+
59+
// Read/Write/Deprecated
60+
dst.ReadOnly <- src.ReadOnly
61+
dst.WriteOnly <- src.WriteOnly
62+
dst.Deprecated <- src.Deprecated
63+
64+
// Compositions & negation
65+
dst.AllOf <-
66+
match src.AllOf with
67+
| null -> null
68+
| a -> ResizeArray(a)
69+
dst.AnyOf <-
70+
match src.AnyOf with
71+
| null -> null
72+
| a -> ResizeArray(a)
73+
dst.OneOf <-
74+
match src.OneOf with
75+
| null -> null
76+
| a -> ResizeArray(a)
77+
dst.Not <- src.Not
78+
79+
// Array/object facets
80+
dst.Items <- src.Items
81+
dst.MaxItems <- src.MaxItems
82+
dst.MinItems <- src.MinItems
83+
dst.UniqueItems <- src.UniqueItems
84+
85+
dst.Properties <-
86+
match src.Properties with
87+
| null -> null
88+
| p -> Dictionary(p)
89+
dst.PatternProperties <-
90+
match src.PatternProperties with
91+
| null -> null
92+
| p -> Dictionary(p)
93+
dst.MaxProperties <- src.MaxProperties
94+
dst.MinProperties <- src.MinProperties
95+
dst.Required <-
96+
match src.Required with
97+
| null -> null
98+
| r -> HashSet(r)
99+
dst.AdditionalPropertiesAllowed <- src.AdditionalPropertiesAllowed
100+
dst.AdditionalProperties <- src.AdditionalProperties
101+
102+
// Misc
103+
dst.Discriminator <- src.Discriminator
104+
dst.Description <- src.Description
105+
dst.Example <- src.Example
106+
dst.Examples <-
107+
match src.Examples with
108+
| null -> null
109+
| e -> ResizeArray(e)
110+
dst.Enum <-
111+
match src.Enum with
112+
| null -> null
113+
| e -> ResizeArray(e)
114+
dst.UnevaluatedProperties <- src.UnevaluatedProperties
115+
dst.ExternalDocs <- src.ExternalDocs
116+
dst.Xml <- src.Xml
117+
dst.Extensions <-
118+
match src.Extensions with
119+
| null -> null
120+
| e -> Dictionary(e)
121+
dst.UnrecognizedKeywords <-
122+
match src.UnrecognizedKeywords with
123+
| null -> null
124+
| e -> Dictionary(e)
125+
dst.Metadata <- if src :? IMetadataContainer then (src :?> IMetadataContainer).Metadata else null
126+
dst.DependentRequired <-
127+
match src.DependentRequired with
128+
| null -> null
129+
| d -> Dictionary(d)
130+
131+
/// Union an existing JsonSchemaType with `null` (OpenAPI 3.1), and also drives `nullable: true` for 3.0.
132+
let unionWithNull (t: Nullable<JsonSchemaType>) : Nullable<JsonSchemaType> =
133+
if t.HasValue then
134+
let combined =
135+
LanguagePrimitives.EnumOfValue( (int t.Value) ||| (int JsonSchemaType.Null) )
136+
Nullable<JsonSchemaType>(combined)
137+
else
138+
// Leave as null; writer will omit 'type'. (Optional: add x-nullable via Extensions if you need 3.0 on typeless schemas.)
139+
Nullable()
140+
141+
// ---------- Transformers ----------
142+
143+
/// 1) Map F# option/valueoption to the **inner T** and mark it nullable.
144+
/// Works with the vNext `Microsoft.OpenApi.OpenApiSchema` model you pasted.
145+
type FSharpOptionSchemaTransformer() =
146+
interface IOpenApiSchemaTransformer with
147+
member _.TransformAsync(schema: OpenApiSchema, ctx: OpenApiSchemaTransformerContext, ct: CancellationToken) : Task =
148+
task {
149+
match ctx.JsonTypeInfo.Type with
150+
| FSharpTypeChecks.FSharpOptionKind innerT ->
151+
// Ask pipeline for T's schema …
152+
let! inner = ctx.GetOrCreateSchemaAsync(innerT, null, ct)
153+
// … copy its shape …
154+
inner |> SchemaCopy.copyTo schema
155+
// … and mark nullable (OAS 3.0 => "nullable: true"; OAS 3.1 => type union with "null")
156+
schema.Type <- SchemaCopy.unionWithNull schema.Type
157+
| _ -> ()
158+
} :> Task
159+
160+
/// 2) On object schemas, ensure option-backed properties are **NOT in `required`**.
161+
/// We use CLR metadata only — no dependency on child schema concrete types.
162+
// type FSharpOptionRequiredPruner() =
163+
// interface IOpenApiSchemaTransformer with
164+
// member _.TransformAsync(schema: OpenApiSchema, ctx: OpenApiSchemaTransformerContext, _ct: CancellationToken) : Task =
165+
// task {
166+
// match ctx.JsonTypeInfo.Kind, schema.Required with
167+
// | JsonTypeInfoKind.Object, NonNull s when s.Count > 0 ->
168+
// for p in ctx.JsonTypeInfo.Properties do
169+
// match p.PropertyType with
170+
// | FSharpTypeChecks.FSharpOptionKind _ ->
171+
// s.Remove(p.Name) |> ignore
172+
// | _ ->
173+
// ()
174+
// | _ ->
175+
// ()
176+
// } :> Task

tests/Oxpecker.Tests/Json.Tests.fs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,18 @@ let ``Test default deserializer`` () =
6060
let! value = serializer.Deserialize<{| Name: string |}>(httpContext)
6161
value |> shouldEqual {| Name = "Oxpecker" |}
6262
}
63+
64+
[<Fact>]
65+
let ``Test default deserializer with nullables`` () =
66+
task {
67+
let serializer: IJsonSerializer = SystemTextJsonSerializer()
68+
let httpContext = DefaultHttpContext()
69+
httpContext.Request.Body <- new MemoryStream()
70+
httpContext.Request.Headers[HeaderNames.ContentType] <- "application/json; charset=utf-8"
71+
use streamWriter = new StreamWriter(httpContext.Request.Body)
72+
streamWriter.Write("""{"name":"Oxpecker"}""")
73+
streamWriter.Flush()
74+
httpContext.Request.Body.Seek(0L, SeekOrigin.Begin) |> ignore
75+
let! value = serializer.Deserialize<{| Name: string | null; Age: Nullable<int>; Title: string | null |}>(httpContext)
76+
value |> shouldEqual {| Name = "Oxpecker"; Age = Nullable(); Title = null |}
77+
}

0 commit comments

Comments
 (0)