Use OData with fsharp and swagger in frontend and backend


The source is on github

Recently, I tried to update Fun.OData.Query with computation expression (CE) DSL, because I really love CE. It is so flexible and can be made with InlineIfLamda and get great performance. Also the CE can make the DSL so clean.

You may argue that the IDE intellisense is not very good for CE, but for me it is good enough as long as it make my code clean.

Of course, if fsharp team can approve that then it will be perfect.  

First let's set up the server

Let's first check the final result for the server setup.
In the swagger you will see things like:


With above inputs you can get result:

  "@odata.count": 1,
  "value": [
      "Roles": [
          "Id": 3,
          "Caption": "Guest",
          "Credential": "1234"
      "Id": 3,
      "Name": "p3"

The actual request url is like:

https://localhost:5001/api/Users?$select=Id,Name,Roles&$filter=Id gt 2&$expand=Roles&$count=true

Now I just use the default core MVC which can support OData very well to start the setup.

Before I made a nuget package **Fun.OData.Giraffe** which can be used to integrate with [Giraffe](, it works well with OData 7.x.x but after OData 8 it is not working any more. I also did not use giraffe for a long time, because my project really need better swagger support. So **Fun.OData.Giraffe** will be deprecated. Besides, there is no other people really use it.

Let's create a OData helper function:

type ODataResult =
        [<JsonPropertyName "@odata.count">]
        Count: int64 option
        Value: IQueryable

type ODataQueryOptions<'T> with
    member queryOptions.Query(source: IQueryable<'T>, ?setQuerySettings) =
        let querySettings =
                PageSize = 30,
                EnsureStableOrdering = false,
                EnableConstantParameterization = true,
                EnableCorrelatedSubqueryBuffering = true

        (defaultArg setQuerySettings ignore) querySettings

        let count =
            if queryOptions.Count <> null then
                let filteredQuery =
                    if queryOptions.Filter = null then
                        source :> IQueryable
                        queryOptions.Filter.ApplyTo(source, querySettings)

                queryOptions.Count.GetEntityCount(filteredQuery) |> Option.ofNullable

            Count = count
            Value = queryOptions.ApplyTo(source, querySettings)

With above code, we can make our code cleaner in MVC controller like:

[<ApiController; Route "/api">]
type Endpoints(db: DemoDbContext) =
    inherit ControllerBase()

    /// Expose for OData query
    [<HttpGet "Roles">]
    member _.GetRoles(options: ODataQueryOptions<_>) = options.Query(db.Roles.AsNoTracking())

    /// Expose for OData query
    [<HttpGet "Users">]
    member _.GetUsers(options: ODataQueryOptions<_>) = options.Query(db.Users.AsNoTracking())

    /// Expose for OData query for single item
    [<HttpGet "Users/{id}"; EnableQuery>]
    member _.GetUsers(id: int) = db.Users.AsNoTracking().Where(fun x -> x.Id = id) |> SingleResult.Create

You will notice that, I did not use the OData default way, because for me I only need to enhance the GET request, so I can write less apis. I do not need mutation related stuff from OData too. And also with this way, all my apis are just pretty normal, you can even combine your own queries with OData's queries together. You just need to provide an IQueryable to it then it is done.

And another important thing is it can integrate very well with entity framework core. It means, it will help generate better SQL. Take the demo query at the beginning, the generated SQL is:

SELECT "t"."Id", "t0"."Id", "t0"."Caption", "t0"."Credential", "t0"."UserId", "t"."Name"
    SELECT "u"."Id", "u"."Name"
    FROM "Users" AS "u"
    WHERE "u"."Id" > @__TypedProperty_0
    LIMIT @__TypedProperty_3
) AS "t"
    SELECT "r"."Id", "r"."Caption", "r"."Credential", "r"."UserId"
    FROM "Roles" AS "r"
) AS "t0" ON "t"."Id" = "t0"."UserId"
ORDER BY "t"."Id", "t0"."Id"

And if I change the request url to:


And then, the SQL will become:

SELECT "u"."Id", "u"."Name"
FROM "Users" AS "u"
LIMIT @__TypedProperty_1

Then we will setup the swagger to make our api exploring experience better. What we need to do is:

  1. Ignore the parameter which its type is ODataQueryOptions, because it will make swagger page very ugly.

  2. Then we will set some most used OData queries available for the swagger page, like $count, $select etc. Even we will not get autocomplete in swagger page, but at least it is usable from swagger page.

type OpenApiOperationIgnoreFilter() =

    let shouldIgnore (parameterDescription: ApiParameterDescription) =
        match parameterDescription.ModelMetadata with
        | :? DefaultModelMetadata as metadata ->
            let isODataQuery () =
                if metadata.UnderlyingOrModelType <> null && metadata.UnderlyingOrModelType.FullName <> null then

            let hasIgnoreAttribute () =
                if metadata.Attributes.ParameterAttributes <> null then
                    metadata.Attributes.ParameterAttributes.Any(fun attribute -> attribute.GetType() = typeof<OpenApiParameterIgnoreAttribute>)

            isODataQuery () || hasIgnoreAttribute ()

        | _ -> false

    let makeQuery name ty =
            Name = name,
            AllowReserved = true,
            AllowEmptyValue = false,
            Required = false,
            In = ParameterLocation.Query,
            Schema = OpenApiSchema(Type = ty)

    let addODataQuery (ps: System.Collections.Generic.IList<OpenApiParameter>) =
        ps.Add(makeQuery "$select" "string")
        ps.Add(makeQuery "$filter" "string")
        ps.Add(makeQuery "$expand" "string")

    interface IOperationFilter with

        member _.Apply(operation, context) =
            if operation <> null
               && context <> null
               && context.ApiDescription <> null
               && context.ApiDescription.ParameterDescriptions <> null then

                let mutable isODataOperation = false
                let mutable isODataQueryAdded = false

                |> Seq.filter shouldIgnore
                |> Seq.iter (fun parameterToHide ->
                    isODataOperation <- true

                    let parameter =
                        operation.Parameters.FirstOrDefault(fun parameter ->
                            String.Equals(parameter.Name, parameterToHide.Name, System.StringComparison.Ordinal)
                    if parameter <> null then
                        operation.Parameters.Remove(parameter) |> ignore
                        if not isODataQueryAdded then
                            operation.Parameters |> addODataQuery
                            operation.Parameters.Add(makeQuery "$top" "integer")
                            operation.Parameters.Add(makeQuery "$skip" "integer")
                            operation.Parameters.Add(makeQuery "$count" "bool")
                            isODataQueryAdded <- true

                if not isODataQueryAdded
                   && context.MethodInfo.CustomAttributes.Any(fun x -> x.AttributeType = typeof<EnableQueryAttribute>) then
                    operation.Parameters |> addODataQuery
                    isODataOperation <- true

                if isODataOperation then
                    operation.Responses.Add("200", OpenApiResponse(Content = dict [ "application/json", OpenApiMediaType() ]))
                    for resp in operation.Responses do
                        resp.Value.Content <- resp.Value.Content.Where(fun kv -> kv.Key.StartsWith "application/json;odata" |> not) |> Dictionary

Finally, you can setup the startup file for your core application:

services.AddControllersWithViews().AddOData(fun options -> options.EnableQueryFeatures() |> ignore)
services.AddSwaggerGen(fun options ->
    let securiyReq = OpenApiSecurityRequirement()
    securiyReq.Add(OpenApiSecurityScheme(Reference = OpenApiReference(Type = ReferenceType.SecurityScheme, Id = "Bearer")), [||])
    options.OperationFilter<OpenApiOperationIgnoreFilter>() |> ignore

The helper functions for OData and swagger can be just copied and used in your project directly. It should be very simple, but it can really simplify things a lot, especially if you are developing internal enterprise application which need to consume a lot of different data from your frontend application.

Frontend demo

Fun.Data.Query can be used to generate the OData queries with type safety and cleaner way. For example, you can write things like like:

type Role = { Caption: string }

type User = { Id: int; Name: string; Roles: Role list }

odata<User> () {
    filter (
        odataAnd () {
            gt (fun x -> x.Id) 2

The generated url is similar as what the swagger will do:

$select=Id,Name,Roles&$filter=Id gt 2&$expand=Roles&$count=true
We define the types in the frontend, it is used indicates what information we want according to our actual use case. For example, to get a brief list of paged items back, then drill down to detail information.

In the demo, I use Fun.Blazor. For fetching the data, I extended the IComponentHook with below two methods:

// Only fetch Id,Name
member hook.LoadUsers(?top, ?page) =
        odata () {
            orderBy (fun x -> x.Name)
            skip (page |> (fun p -> p * 10))
            take top
    |> (
        | Ok x -> DeferredState.Loaded x.Value
        | Error e -> DeferredState.LoadFailed e

// Fetch detail information
member hook.LoadUserDetail(id: int) =
    hook.ODataSingle<User>($"/api/Users/{id}", odata () { empty }) |> DeferredState.ofResult
**ODataQuery** and **ODataSingle** is just a simple helper method which is written in the demo project. They will inject HttpClient and compose the url path.


I really like OData, but just use it in a very light way to enhance my RESTful APIs without make it complex. Also to consume it with Fun.OData.Query make it very nice and clean too. Anyway, with those things, I can build stuff very productively, at least for internal enterprise application.

Do you like this post?