Skip to content

Instantly share code, notes, and snippets.

@michaeloyer
Last active May 28, 2024 01:19
Show Gist options
  • Save michaeloyer/40f0fa9882ef50c2f985d61bc6e917e9 to your computer and use it in GitHub Desktop.
Save michaeloyer/40f0fa9882ef50c2f985d61bc6e917e9 to your computer and use it in GitHub Desktop.
Better Support for F# Option<'T> in Swagger

Description

This is a Minimal API written for a net6.0 ASP.NET Web server. the Program.fs file will be the only F# file you need to add to a new F# ASP.NET Web app that can be generated with:

dotnet new web -lang F#

Intent

To replace the Option<'T> type with the 'T type in generated Swagger schemas

Output

swagger.json is the generated swagger document from running Program.fs

The Parent type will change from this output:

{
  "child": {
    "value": {
      "text": "string"
    }
  }
}

To this output:

{
  "child": {
    "text": "string"
  }
}
open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.FSharp.Core
open Microsoft.OpenApi.Models
open Swashbuckle.AspNetCore.SwaggerGen
type Child = { Text: string }
type Parent = { Child: Child option }
// Replaces all Option<'T> types and types with Properties that have Option<'T> with the corresponding 'T type
type OptionSchemaFilter() =
let isFSharpOption (t:Type) =
// There's got to be a better way to test if it's the generic Option<'T> type,
// but I couldn't figure it out and settled for this instead
t.Name = "FSharpOption`1" && t.Namespace = "Microsoft.FSharp.Core"
interface ISchemaFilter with
member x.Apply(schema: OpenApiSchema, context: SchemaFilterContext) =
if isFSharpOption context.Type then
let argumentType = context.Type.GetGenericArguments()[0]
let argumentSchema = context.SchemaGenerator.GenerateSchema(argumentType, context.SchemaRepository)
schema.Reference <- argumentSchema.Reference
else
for propertyInfo in context.Type.GetProperties() do
if isFSharpOption propertyInfo.PropertyType then
let argumentType = propertyInfo.PropertyType.GetGenericArguments()[0]
let argumentSchema = context.SchemaGenerator.GenerateSchema(argumentType, context.SchemaRepository)
// There is probably a better way to generate the property name.
// This seems like it could have edge cases
let camelCasePropertyName = String [|
yield Char.ToLower(propertyInfo.Name[0])
yield! propertyInfo.Name[1..]
|]
schema.Properties[camelCasePropertyName].Reference <- argumentSchema.Reference
// Removes the "*FSharpOption" added types from the schema list
type OptionDocumentFilter() =
interface IDocumentFilter with
member this.Apply(swaggerDoc, _) =
for key in swaggerDoc.Components.Schemas.Keys do
if key.EndsWith("FSharpOption") then
swaggerDoc.Components.Schemas.Remove(key) |> ignore
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
builder.Services.AddSwaggerGen(fun options ->
options.SchemaFilter<OptionSchemaFilter>()
options.DocumentFilter<OptionDocumentFilter>()
) |> ignore
builder.Services.AddEndpointsApiExplorer() |> ignore
let app = builder.Build()
app.UseSwagger() |> ignore
app.UseSwaggerUI() |> ignore
app.MapPost("/test", Func<_,_>(
fun ([<FromBody>]s:Parent) ->
System.Text.Json.JsonSerializer.Serialize(s))
) |> ignore
app.Run()
0
{
"openapi": "3.0.1",
"info": {
"title": "FsWebApp",
"version": "1.0"
},
"paths": {
"/test": {
"post": {
"tags": [
"Pipe #5 input at line 62@62"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Parent"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Child": {
"type": "object",
"properties": {
"text": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"Parent": {
"type": "object",
"properties": {
"sub": {
"$ref": "#/components/schemas/Child"
}
},
"additionalProperties": false
}
}
}
}
@natalie-o-perret
Copy link

natalie-o-perret commented Feb 27, 2023

Improving some bits with resp.

  • Using Erik's TypeShape to make it easier and neater to spot F# options in the payload
  • Changing the sample data structure, clearly marking what fields Some and None
  • Marking F# option fields as Nullable <- true
  • Adding some printfn for while-dev-debugging-purposes
  • Using JsonNamingPolicy.CamelCase.ConvertName(propertyInfo.Name) for consistent camel casing property names
  • Enabling Swashbuckle annotations for minimal ASP.NET Web APIs as well as,
  • Adding some dummy tags, name and description to the endpoint
open System
open System.Text.Json

open TypeShape.Core.Core

open Microsoft.FSharp.Core
open Microsoft.OpenApi.Models
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection

open Swashbuckle.AspNetCore.Annotations
open Swashbuckle.AspNetCore.SwaggerGen


type GrandChild =
    { DoubleOptionSome: double option
      Int32OptionSome: int32 option
      StringOptionSome: string option
      DoubleOptionNone: double option
      Int32OptionNone: int32 option
      StringOptionNone: string option
      Double: double
      Int32: int32
      String: string }

type Child =
    { DoubleOptionSome: double option
      Int32OptionSome: int32 option
      StringOptionSome: string option
      DoubleOptionNone: double option
      Int32OptionNone: int32 option
      StringOptionNone: string option
      Double: double
      Int32: int32
      String: string
      Child: GrandChild option
      AnotherText: string option }

type Parent =
    { DoubleOptionSome: double option
      Int32OptionSome: int32 option
      StringOptionSome: string option
      DoubleOptionNone: double option
      Int32OptionNone: int32 option
      StringOptionNone: string option
      Double: double
      Int32: int32
      String: string
      Child: Child
      ChildOptionSome: Child option
      ChildOptionNone: Child option }

// Replaces all Option<'T> types and types with Properties that have Option<'T> with the corresponding 'T type
type OptionSchemaFilter() =
    let tryParseFSharpOption t =
        match TypeShape.Create(t) with
        | Shape.FSharpOption valueTypeShape -> true, valueTypeShape.Element.Type
        | _ -> false, Unchecked.defaultof<Type>

    interface ISchemaFilter with
        member x.Apply(schema: OpenApiSchema, context: SchemaFilterContext) =
            match tryParseFSharpOption context.Type with
            | true, elementType ->
                let argumentSchema =
                    context.SchemaGenerator.GenerateSchema(elementType, context.SchemaRepository)

                printfn $"%A{argumentSchema.Reference}: %A{elementType}"
                schema.Reference <- argumentSchema.Reference
                schema.Nullable <- true
            | false, _ ->
                for propertyInfo in context.Type.GetProperties() do
                    match tryParseFSharpOption propertyInfo.PropertyType with
                    | true, argumentType ->
                        let argumentSchema =
                            context.SchemaGenerator.GenerateSchema(argumentType, context.SchemaRepository)

                        let camelCasePropertyName = JsonNamingPolicy.CamelCase.ConvertName(propertyInfo.Name)
                        schema.Properties[camelCasePropertyName].Reference <- argumentSchema.Reference
                        schema.Properties[camelCasePropertyName].Type <- argumentSchema.Type
                        schema.Properties[camelCasePropertyName].Nullable <- true
                        printfn $"%s{propertyInfo.Name}: %A{argumentType}"
                    | _ -> ()

// Removes the "*FSharpOption" added types from the schema list
type OptionDocumentFilter() =
    interface IDocumentFilter with
        member this.Apply(swaggerDoc, _) =
            for key in swaggerDoc.Components.Schemas.Keys do
                if key.EndsWith("FSharpOption") then
                    swaggerDoc.Components.Schemas.Remove(key) |> ignore

[<EntryPoint>]
let main args =

    let builder = WebApplication.CreateBuilder(args)

    builder.Services.AddSwaggerGen(fun options ->
        options.EnableAnnotations()
        options.SchemaFilter<OptionSchemaFilter>()
        options.DocumentFilter<OptionDocumentFilter>())
    |> ignore

    builder.Services.AddEndpointsApiExplorer() |> ignore

    let app = builder.Build()

    app.UseSwagger() |> ignore
    app.UseSwaggerUI() |> ignore

    let createEpMetadata summary description tags =
        let attr = SwaggerOperationAttribute(summary = summary, description = description)
        attr.Tags <- tags
        attr

    app
        .MapGroup("/api/group")
        .MapPost(
            "test",
            Func<_, _>(fun ([<FromBody>] s: Parent) ->
                printfn $"%A{s}"
                let options = JsonSerializerOptions()
                options.WriteIndented <- true
                options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
                JsonSerializer.Serialize(s, options))
        )
        .WithMetadata(createEpMetadata "fart in the wide ocean" "a very loud bubbling one" [| "miau" |])
    |> ignore

    app.Run()

    0

Input Sample:

{
  "doubleOptionSome": 0,
  "int32OptionSome": 0,
  "stringOptionSome": "string",
  "doubleOptionNone": null,
  "int32OptionNone": null,
  "stringOptionNone": null,
  "double": 0,
  "int32": 0,
  "string": "string",
  "child": {
    "doubleOptionSome": 0,
    "int32OptionSome": 0,
    "stringOptionSome": "string",
    "doubleOptionNone": null,
    "int32OptionNone": null,
    "stringOptionNone": null,
    "double": 0,
    "int32": 0,
    "string": "string",
    "child": null,
    "anotherText": "string"
  },
  "childOptionSome": {
    "doubleOptionSome": 0,
    "int32OptionSome": 0,
    "stringOptionSome": "string",
    "doubleOptionNone": null,
    "int32OptionNone": null,
    "stringOptionNone": null,
    "double": 0,
    "int32": 0,
    "string": "string",
    "child": {
      "doubleOptionSome": 0,
      "int32OptionSome": 0,
      "stringOptionSome": "string",
      "doubleOptionNone": null,
      "int32OptionNone": null,
      "stringOptionNone": null,
      "double": 0,
      "int32": 0,
      "string": "string"
    },
    "anotherText": "string"
  },
  "childOptionNone": null
}

and the relevant debugging output sample:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7217
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5192
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\Users\natalie-perret\Desktop\FSharpWebApi
<null>: System.Double
<null>: System.Int32
<null>: System.String
DoubleOptionSome: System.Double
Int32OptionSome: System.Int32
StringOptionSome: System.String
DoubleOptionNone: System.Double
Int32OptionNone: System.Int32
StringOptionNone: System.String
Microsoft.OpenApi.Models.OpenApiReference: Program+GrandChild
DoubleOptionSome: System.Double
Int32OptionSome: System.Int32
StringOptionSome: System.String
DoubleOptionNone: System.Double
Int32OptionNone: System.Int32
StringOptionNone: System.String
Child: Program+GrandChild
AnotherText: System.String
Microsoft.OpenApi.Models.OpenApiReference: Program+Child
DoubleOptionSome: System.Double
Int32OptionSome: System.Int32
StringOptionSome: System.String
DoubleOptionNone: System.Double
Int32OptionNone: System.Int32
StringOptionNone: System.String
ChildOptionSome: Program+Child
ChildOptionNone: Program+Child
{ DoubleOptionSome = Some 0.0
  Int32OptionSome = Some 0
  StringOptionSome = Some "string"
  DoubleOptionNone = None
  Int32OptionNone = None
  StringOptionNone = None
  Double = 0.0
  Int32 = 0
  String = "string"
  Child = { DoubleOptionSome = Some 0.0
            Int32OptionSome = Some 0
            StringOptionSome = Some "string"
            DoubleOptionNone = None
            Int32OptionNone = None
            StringOptionNone = None
            Double = 0.0
            Int32 = 0
            String = "string"
            Child = None
            AnotherText = Some "string" }
  ChildOptionSome = Some { DoubleOptionSome = Some 0.0
                           Int32OptionSome = Some 0
                           StringOptionSome = Some "string"
                           DoubleOptionNone = None
                           Int32OptionNone = None
                           StringOptionNone = None
                           Double = 0.0
                           Int32 = 0
                           String = "string"
                           Child = Some { DoubleOptionSome = Some 0.0
                                          Int32OptionSome = Some 0
                                          StringOptionSome = Some "string"
                                          DoubleOptionNone = None
                                          Int32OptionNone = None
                                          StringOptionNone = None
                                          Double = 0.0
                                          Int32 = 0
                                          String = "string" }
                           AnotherText = Some "string" }
  ChildOptionNone = None }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment