OpenAPI
ASP.NET Core's OpenAPI generator, Microsoft.AspNetCore.OpenApi, describes minimal API endpoints from the parameters and
return type of their handler. Endpoints mapped with Orsak.AspNetCore's MapEffectEndpoints have a request delegate
instead, so the generator leaves them out. Orsak.AspNetCore.OpenApi adds them, describing each from what can be read
from it: its route, the function handling it, that function's XML docs, the type of its effect, and the responses its
runner describes. Explicit metadata, such as WithName or Produces, comes first.
It needs .NET 10, and Microsoft.AspNetCore.OpenApi 10.0.14 or later.
This page is a script: it runs with dotnet fsi docs/openapi.fsx once Orsak is built, after
dotnet fsi docs/.tools/aspnetcore-references.fsx, as for Web APIs, and the output shown is from such
a run.
Setting up
Reference Orsak.AspNetCore.OpenApi, and add the effect endpoints to the document with AddEffectEndpoints.
AddFSharpSchemas is optional, and describes F# types as System.Text.Json writes them:
builder.Services.AddOpenApi(fun options -> options.AddEffectEndpoints().AddFSharpSchemas() |> ignore)
app.MapOpenApi()
The endpoints are found through ASP.NET Core's routing, which knows of them once UseEndpoints has registered them,
as a started application does.
An API to describe
The API from Web APIs looks orders up. Its handlers return the order itself, so the type of their effect tells what a successful response holds:
open System
open System.Text.Json.Nodes
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Mvc
open Microsoft.AspNetCore.OpenApi
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.OpenApi
open Orsak
open Orsak.AspNetCore
open Orsak.AspNetCore.OpenApi
open Orsak.AspNetCore.V2
type OrderLine = { Product: string; Quantity: int }
type Order = {
Id: int
Lines: OrderLine list
Note: string option
}
type OrderError =
| NotFound of id: int
| Invalid of reason: string
let getOrder (id: int) : Effect<unit, Order, OrderError> = eff {
if id = 1 then
return { Id = 1; Lines = [ { Product = "mug"; Quantity = 2 } ]; Note = None }
else
return! Error(NotFound id)
}
let getOrderLine (id: int) (line: int) : Effect<unit, OrderLine, OrderError> = eff {
let! order = getOrder id
match List.tryItem line order.Lines with
| Some l -> return l
| None -> return! Error(NotFound id)
}
The runner's responses
Only the runner knows how an effect's result and errors become responses. A runner that implements
IDescribeResponses says so, given the type of each endpoint's effect. This one writes the result as JSON, and the
errors as problem details:
type Runner =
| Runner
static member inline ( *>> )(effect: Effect<unit, 'a, OrderError>, Runner) =
RequestDelegate(fun ctx -> task {
let! response =
effect
|> Effect.map Results.Ok
|> Effect.run ()
let response =
match response with
| Ok response -> response
| Error(NotFound _) -> Results.Problem(statusCode = 404)
| Error(Invalid reason) -> Results.Problem(reason, statusCode = 400)
return! response.ExecuteAsync ctx
})
interface IDescribeResponses with
member _.Responses effectType =
match effectType.GetGenericArguments() with
| [| _; result; _ |] -> [
ResponseDescription.Json(200, result)
ResponseDescription.Json(400, typeof<ProblemDetails>, "The request is invalid")
ResponseDescription.Json(404, typeof<ProblemDetails>, "Not found")
]
| _ -> []
Each endpoint gets the responses as metadata, a ResponseDescription each, which is also an
IProducesResponseTypeMetadata for other tools that read ApiExplorer's metadata.
openApiOf maps endpoints, and returns the document the generator creates, as JSON:
let openApiOf (configure: OpenApiOptions -> OpenApiOptions) (endpoints: Orsak.AspNetCore.Endpoint list) =
let builder = WebApplication.CreateSlimBuilder()
builder.Logging.ClearProviders() |> ignore
builder.Services.AddOpenApi(fun options -> configure options |> ignore) |> ignore
let app = builder.Build()
app.UseRouting().UseEndpoints(fun e -> e.MapEffectEndpoints endpoints) |> ignore
let provider = app.Services.GetRequiredKeyedService<IOpenApiDocumentProvider>("v1")
let document = provider.GetOpenApiDocumentAsync().Result
document.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1).Result |> JsonNode.Parse
let show (node: JsonNode) = printfn "%s" (node.ToJsonString(Text.Json.JsonSerializerOptions(WriteIndented = true)))
/// The node at the end of `keys`, such as the path item of a route.
let at (keys: string list) (node: JsonNode) = keys |> List.fold (fun (n: JsonNode) key -> n[key]) node
let endpoints = [
Runner.RouteGet2("/orders/%i", getOrder)
Runner.RouteGet2("/orders/%i/lines/%i", getOrderLine)
]
What is inferred
With nothing written for OpenAPI, each endpoint is an operation named after its handler, tagged with the handler's module, with its route values as path parameters, typed by their format specifiers, and the responses of its runner:
let document = openApiOf (fun o -> o.AddEffectEndpoints()) endpoints
document |> at [ "paths"; "/orders/{id}/lines/{line}" ] |> show
|
The tag is the module of getOrderLine, which in a script is one F# Interactive names. The schemas are components of
the document, named as the generator names its own, by OpenApiOptions.CreateSchemaReferenceId.
Without a runner that describes its responses, an operation has a 200 with the effect's result as its body, unless
the result is unit or an IResult, which tell nothing of the body.
From the XML docs
The handler's XML docs describe the operation, when the project writes them, with GenerateDocumentationFile:
|
They are read from the .xml file next to the handler's assembly. A script has none, so this part is from a project,
the one of Orsak's tests:
/// <summary>Gets an order, with its lines.</summary>
/// <remarks>
/// The lines are in the order they were added. See <see cref="T:Orsak.Tests.OpenApiInferenceTests.Orders.OrderLine"/>.
/// </remarks>
/// <param name="orderId">The id of the order.</param>
/// <returns>The order.</returns>
/// <response code="404">No order has the id.</response>
let getOrder (orderId: int) : Effect<unit, Order, OrderError> = ...
The <summary> is the operation's summary, the <remarks> its description, each <param> describes the route value
of the same name, <returns> the success response, and each <response code="..."> the response with that status
code, which it adds if the runner doesn't describe one:
|
F# only reads a doc comment as XML when it starts with a tag. A comment of plain text is its summary, tags and all, so
write <summary> when the comment has other tags.
Explicit metadata
Metadata on the endpoint comes before what is inferred, and before the docs. The standard conventions apply, through
AddConvention, and a ResponseDescription adds a response, or replaces the runner's for its status code:
let described =
Runner
.RouteGet2("/orders/%i", getOrder)
.WithName("GetOrder")
.WithMetadata(ResponseDescription.Empty(404, "No order has the id"))
.AddConvention(fun b -> b.WithSummary("Gets an order").WithTags("Orders"))
let explicitDocument = openApiOf (fun o -> o.AddEffectEndpoints()) [ described ]
let operation = explicitDocument |> at [ "paths"; "/orders/{id}"; "get" ]
printfn "%s, %s, %O" (operation["operationId"].ToString()) (operation["summary"].ToString()) (operation["tags"].ToJsonString())
for response in operation["responses"].AsObject() do
printfn "%s: %s" response.Key (response.Value["description"].ToString())
|
The precedence, for each part of an operation:
Part |
First |
Then |
Then |
|---|---|---|---|
Operation id |
|
the handler's name |
|
Tags |
|
the handler's module |
|
Summary |
|
|
|
Description |
|
|
|
Path parameters |
|
typed by their route constraints |
|
Responses |
|
|
the runner's, or a 200 of the effect's result |
Response descriptions |
the endpoint's metadata |
the docs |
the runner's |
A handler's name is only an operation id once, as for a function handling two routes, since operation ids are unique.
An endpoint is in the documents of its group name, set with WithGroupName, or in every document without one, and
ExcludeFromDescription leaves it out.
F# types
The generator describes types as System.Text.Json writes them, and knows .NET's types, not F#'s. An option is written
as its value, or null for none, but the generator describes it as a component of its own, and requires it in a
record, though a missing option field is read as None. An F# list is never null, but is described as one that can
be:
document |> at [ "components"; "schemas"; "Order" ] |> show
|
let fsharpDocument = openApiOf (fun o -> o.AddEffectEndpoints().AddFSharpSchemas()) endpoints
fsharpDocument |> at [ "components"; "schemas"; "Order" ] |> show
|
val string: value: 'T -> string
--------------------
type string = String
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
union case Effect.Effect: EffectDelegate<'r,'a,'e> -> Effect<'r,'a,'e>
--------------------
module Effect from Orsak
<summary> Functions for running, combining, recovering and repeating effects. </summary>
--------------------
type Effect = static member Create: f: ('a -> Task<Result<'b,'e>>) -> Effect<'a,'b,'e> + 2 overloads static member Error: error: 'e -> Effect<'a,'b,'e>
<summary> Creates effects. <c>Effect.Create</c> turns a function of an environment into an effect, and is how effects are made from the interfaces that describe side effects. The function may return a plain value, a <c>Result</c>, a <c>Task</c>, a <c>ValueTask</c> or an <c>Async</c>, of a value or of a <c>Result</c>; returning a <c>Result</c> lets the effect fail. </summary>
<example> The usual pattern: an interface describing the side effect, a provider interface exposing it, and a function creating the effect, which requires any environment that implements the provider (<c>#IConsoleProvider</c>). <code lang="fsharp"> type IConsole = abstract ReadLine: unit -> string abstract WriteLine: string -> unit type IConsoleProvider = abstract Console: IConsole module Console = let readLine () = Effect.Create(fun (p: #IConsoleProvider) -> p.Console.ReadLine()) let writeLine line = Effect.Create(fun (p: #IConsoleProvider) -> p.Console.WriteLine line) </code> The Orsak.Myriad generators can write the functions of such a module from the interface. </example>
--------------------
type Effect<'r,'a,'e> = | Effect of EffectDelegate<'r,'a,'e> member Run: r: 'r -> AsyncResult<'a,'e> member RunOrFail: r: 'r -> Task<'a> static member (<*>)<'env,'arg,'res,'a (requires member (+))> : f: Effect<'env,('arg -> 'res),'a0> * e: Effect<'env,'arg,'a0> -> Effect<'env,'res,'a0> (requires member (+)) static member (>>=) : h: Effect<'r,'a,'e> * f: ('a -> Effect<'r,'b,'e>) -> Effect<'r,'b,'e> static member Map: h: Effect<'r,'a,'e> * f: ('a -> 'b) -> Effect<'r,'b,'e> static member Return<'a,'b,'c> : a: 'a0 -> Effect<'b,'a0,'c> static member ap<'r,'a,'b,'e> : applicative: Effect<'r,('b -> 'a),'e> -> e: Effect<'r,'b,'e> -> Effect<'r,'a,'e>
<summary> Describes an effect that can either succeed with <typeparamref name="'a" />, or fail with <typeparamref name="'e" />. The effect is 'cold', and only starts when run with an <typeparamref name="'r" />. </summary>
<remarks> Effects are usually written with the <c>eff</c> computation expression, and created from interfaces that describe side effects with <c>Effect.Create</c>. The environment <typeparamref name="'r" /> is inferred from what the effect uses, and supplied when the effect is run. </remarks>
<example><code lang="fsharp"> let greet () = eff { let! name = Console.readLine () do! Console.writeLine $"Hello {name}" } // at the composition root, with an environment providing the console effects: let! result = greet () |> Effect.run env </code></example>
<typeparam name="'r"> The environment required to run the effect </typeparam>
<typeparam name="'a"> The resulting type when the effect runs successfully </typeparam>
<typeparam name="'e"> The resulting type when the effect fails</typeparam>
<summary> The computation expression for writing effects. Binding (<c>let!</c>, <c>do!</c>, <c>return!</c>) works with other effects, and with <c>Task</c>, <c>ValueTask</c>, <c>Async</c> and <c>Result</c>, so .NET APIs can be used directly. It supports <c>for</c>, <c>while</c>, <c>use</c>, <c>try/with</c> and <c>try/finally</c>, and <c>and!</c> runs effects concurrently. A failed effect stops the rest of the expression, with its error. </summary>
<example><code lang="fsharp"> let placeOrder (order: Order) = eff { let! id = GuidGenerator.genGuid () let! now = Time.utcNow () do! Orders.save { order with Id = id; Placed = now } let! customer = Customers.load order.CustomerId and! stock = Stock.reserve order.Lines return id } </code></example>
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
union case Runner.Runner: Runner
--------------------
type Runner = | Runner interface IDescribeResponses static member ( *>> ) : effect: Effect<unit,'a,OrderError> * Runner: Runner -> RequestDelegate
<summary> map of the effect. </summary>
<param name="f">The continuation function to run in case <paramref name="e" /> succeeds.</param>
<param name="e">The effect</param>
Results.Ok(?value: obj) : IResult
<summary> Starts the effect. </summary>
<typeparam name="'r"> The environment required to run the effect </typeparam>
<typeparam name="'a"> The resulting type when the effect runs successfully </typeparam>
<typeparam name="'e"> The resulting type when the effect fails</typeparam>
<param name="env">The environment needed to start the effect</param>
<param name="e">The effect to run</param>
<example><code lang="fsharp"> task { match! placeOrder order |> Effect.run env with | Ok id -> printfn $"Placed {id}" | Error err -> printfn $"Failed: {err}" } </code></example>
Results.Problem(?detail: string, ?instance: string, ?statusCode: Nullable<int>, ?title: string, ?``type`` : string, ?extensions: Collections.Generic.KeyValuePair<string,obj> seq) : IResult
Results.Problem(detail: string, instance: string, statusCode: Nullable<int>, title: string, ``type`` : string, extensions: Collections.Generic.IDictionary<string,obj>) : IResult
<summary> A runner that describes the responses it gives, for OpenAPI: only the runner knows how an effect's result and errors become responses. When the runner of an endpoint implements it, the endpoint gets the responses as metadata. </summary>
<example><code lang="fsharp"> type Runner = | RunWith of (HttpContext -> AppEnv) static member ( *>> )(effect: Effect<AppEnv, 'a, AppError>, RunWith createEnv) = ... interface IDescribeResponses with member _.Responses effectType = match effectType.GetGenericArguments() with | [| _; result; _ |] -> [ ResponseDescription.Json(200, result) ResponseDescription.Json(404, typeof<ProblemDetails>, "Not found") ] | _ -> [] </code></example>
<summary> A response an endpoint can give: its status code, the type of its body, if it has one, and what it means. As endpoint metadata, it is an <see cref="T:Microsoft.AspNetCore.Http.Metadata.IProducesResponseTypeMetadata" />, which ApiExplorer and OpenAPI generators read. </summary>
type ProblemDetails = new: unit -> unit member Detail: string member Extensions: IDictionary<string,obj> member Instance: string member Status: Nullable<int> member Title: string member Type: string
--------------------
ProblemDetails() : ProblemDetails
type OpenApiOptions = new: unit -> unit member AddDocumentTransformer<'TTransformerType (requires 'TTransformerType :> IOpenApiDocumentTransformer)> : unit -> OpenApiOptions + 2 overloads member AddOperationTransformer<'TTransformerType (requires 'TTransformerType :> IOpenApiOperationTransformer)> : unit -> OpenApiOptions + 2 overloads member AddSchemaTransformer<'TTransformerType (requires 'TTransformerType :> IOpenApiSchemaTransformer)> : unit -> OpenApiOptions + 2 overloads static member CreateDefaultSchemaReferenceId: jsonTypeInfo: JsonTypeInfo -> string member CreateSchemaReferenceId: Func<JsonTypeInfo,string> member DocumentName: string member OpenApiVersion: OpenApiSpecVersion member ShouldInclude: Func<ApiDescription,bool>
<summary> Options to support the construction of OpenAPI documents. </summary>
--------------------
OpenApiOptions() : OpenApiOptions
union case Endpoint.Endpoint: {| conventions: (IEndpointConventionBuilder -> IEndpointConventionBuilder); path: string; requestDelegate: RequestDelegate; verb: string |} -> Endpoint
--------------------
type Endpoint = | Endpoint of {| conventions: (IEndpointConventionBuilder -> IEndpointConventionBuilder); path: string; requestDelegate: RequestDelegate; verb: string |} member Add: f: Action<EndpointBuilder> -> Endpoint member AddConvention: f: (IEndpointConventionBuilder -> IEndpointConventionBuilder) -> Endpoint member AllowAnonymous: unit -> Endpoint member RequireCors: name: string -> Endpoint + 1 overload member RequiresAuthorization: unit -> Endpoint member WithDisplayName: name: string -> Endpoint + 1 overload member WithGroupName: name: string -> Endpoint member WithMetadata: items: obj array -> Endpoint member WithName: name: string -> Endpoint
<summary> An endpoint created by one of the <c>Route*</c> methods, such as <c>RouteGet</c>, to be mapped with <c>MapEffectEndpoints</c>. Its methods add conventions, as on ASP.NET Core's endpoint convention builders. </summary>
<example><code lang="fsharp"> runner.RouteGet("/users/%i", getUser) .RequiresAuthorization() .WithName("GetUser") </code></example>
WebApplication.CreateSlimBuilder(options: WebApplicationOptions) : WebApplicationBuilder
WebApplication.CreateSlimBuilder(args: string array) : WebApplicationBuilder
(extension) IServiceCollection.AddOpenApi(documentName: string) : IServiceCollection
(extension) IServiceCollection.AddOpenApi(configureOptions: Action<OpenApiOptions>) : IServiceCollection
(extension) IServiceCollection.AddOpenApi(documentName: string, configureOptions: Action<OpenApiOptions>) : IServiceCollection
(extension) IServiceProvider.GetRequiredKeyedService(serviceType: Type, serviceKey: obj) : obj
<summary> Represents a provider for OpenAPI documents that can be used by consumers to retrieve generated OpenAPI documents at runtime. </summary>
module Result from Microsoft.FSharp.Core
--------------------
type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
(extension) OpenApiDocument.SerializeAsJsonAsync<'T (requires 'T :> IOpenApiSerializable)>(stream: IO.Stream, specVersion: OpenApiSpecVersion, ?cancellationToken: Threading.CancellationToken) : Task
<summary> Represents versions of OpenAPI specification. </summary>
<summary>The base class that represents a single node within a mutable JSON document.</summary>
JsonNode.Parse( json: string, ?nodeOptions: Nullable<JsonNodeOptions>, ?documentOptions: Text.Json.JsonDocumentOptions) : JsonNode
JsonNode.Parse(utf8Json: ReadOnlySpan<byte>, ?nodeOptions: Nullable<JsonNodeOptions>, ?documentOptions: Text.Json.JsonDocumentOptions) : JsonNode
JsonNode.Parse(utf8Json: IO.Stream, ?nodeOptions: Nullable<JsonNodeOptions>, ?documentOptions: Text.Json.JsonDocumentOptions) : JsonNode
type JsonSerializerOptions = new: unit -> unit + 2 overloads member AddContext<'TContext (requires default constructor and 'TContext :> JsonSerializerContext)> : unit -> unit member GetConverter: typeToConvert: Type -> JsonConverter member GetTypeInfo: ``type`` : Type -> JsonTypeInfo member MakeReadOnly: unit -> unit + 1 overload member TryGetTypeInfo: ``type`` : Type * typeInfo: byref<JsonTypeInfo> -> bool member AllowDuplicateProperties: bool member AllowOutOfOrderMetadataProperties: bool member AllowTrailingCommas: bool member Converters: IList<JsonConverter> ...
<summary>Provides options to be used with <see cref="T:System.Text.Json.JsonSerializer" />.</summary>
--------------------
Text.Json.JsonSerializerOptions() : Text.Json.JsonSerializerOptions
Text.Json.JsonSerializerOptions(defaults: Text.Json.JsonSerializerDefaults) : Text.Json.JsonSerializerOptions
Text.Json.JsonSerializerOptions(options: Text.Json.JsonSerializerOptions) : Text.Json.JsonSerializerOptions
The node at the end of `keys`, such as the path item of a route.
<summary>Gets the key in the key/value pair.</summary>
<returns>A <typeparamref name="TKey" /> that is the key of the <see cref="T:System.Collections.Generic.KeyValuePair`2" />.</returns>
<summary>Gets the value in the key/value pair.</summary>
<returns>A <typeparamref name="TValue" /> that is the value of the <see cref="T:System.Collections.Generic.KeyValuePair`2" />.</returns>
Orsak