cognitedata / oryx

.NET Cross platform and highly composable middleware for building web request handlers in F#
Apache License 2.0
202 stars 10 forks source link

Add more details to the response #108

Closed ghost closed 1 year ago

ghost commented 1 year ago

Hi there. Thanks for providing such a neat library to ease http requesting. I've currently started using it for testing some of the APIs, and it seems Oryx misses some basic functionality for this stuff, say access to ResponseCode, ContentType and ResponseHeaders. In Oryx, after |> runAsync what we get is Result<T, exn> where T contains only response body. Is there a way to add another handler, like runAsyncDetailed which will return Result<Details<T>, exn> where Details are something like this:

type ExampleResponseData =
    {
        Result: string
    }

type Details<'TResponseData> =
    {
        StatusCode: int
        Data: 'TResponseData
        ResponseHeaders: Map<string, string>
        ContentType: string
    }
let a =  {
    StatusCode = 200
    Data = {Result = "myData"}
    ResponseHeaders = ["headerKey", "headerValue"] |> Map.ofList
    ContentType = "application/json"
}

This would ease using Oryx for testing purpose instead of FsHttp or RestSharp, or even plain HttpClient.

dbrattli commented 1 year ago

Yes, we have had that problem ourselves. The way we solved it was to have a custom runAsync e.g something like below:

let runAsync<'TResult> (ctx: Microsoft.AspNetCore.Http.HttpContext) (handler: Oryx.HttpHandler<'TResult>) =
    task {
        let statusCode =  extractStatus ctx
        return! handler |> HttpHandler.map (fun result -> (result, statusCode)) |> runAsync
    }
ghost commented 1 year ago

Thanks @dbrattli . Will check shortly if it fits my purpose. Any suggestion at what part of the Oryx code should I take a look to improve this and fire a PR?

ghost commented 1 year ago

@dbrattli the example you've provided doesn't seem to work well in this case. If we take this code as an example

 member _.Initialize() =
        httpRequest
        |> withLogger logger
        |> withLogLevel LogLevel.Information
        |> GET
        |> withHttpClient httpClient
        |> withUrl (baseUrl "/login/initialize")
        |> fetch
        |> json<InitializeResponse> JSON.options
        |> log
...

And later call this method:
task {
    let! response =  sut.Initialize() |> runAsyncDetailed
}

It will fail with type mismatch error, since we didn't provide an HttpContext

The type 'Pipeline.IAsyncNext<HttpContext,InitializeResponse> -> System.Threading.Tasks.Task<unit>' is not compatible with the type 'AspNetCore.Http.HttpContext'

What I tried to do, is to use code similar to Core.parse:

module OryxExtensions =
    open Oryx
    type  Details<'TResponseData> = {
        StatusCode: HttpStatusCode
        Headers: Map<string,seq<string>>
        Data: 'TResponseData
    }
    let private withResult<'TResult> (ctx: HttpContext) (result: 'TResult) : Details<'TResult> =
        {
            StatusCode = ctx.Response.StatusCode
            Headers = ctx.Response.Headers
            Data = result
        }

    let withDetails<'TResult>  (source: HttpHandler<'TResult>) : HttpHandler<Details<'TResult>> =
        fun next ->
            { new IHttpNext<'TResult> with
                member _.OnSuccessAsync(ctx, content: 'TResult) =
                    task {
                        let detailedResult = withResult ctx content
                        return! next.OnSuccessAsync(ctx, detailedResult )
                    }
                member _.OnErrorAsync(ctx, exn) = next.OnErrorAsync(ctx, exn)
                member _.OnCancelAsync(ctx) = next.OnCancelAsync(ctx) }
            |> source

so we can add that into the pipeline, like this:

 member _.Initialize() =
        httpRequest
        |> withLogger logger
        |> withLogLevel LogLevel.Information
        |> GET
        |> withHttpClient httpClient
        |> withUrl (baseUrl "/login/initialize")
        |> fetch
        |> json<InitializeResponse> JSON.options
        |> withDetails
        |> log

and utilize in test scenario:

    [<Fact>]
    member test.``Initialize session``() = task {

        match! sut.Initialize() |> runAsync with
        | Error err -> Assert.Fail err.Message
        | Ok res ->
            Assert.Equal(HttpStatusCode.OK, res.StatusCode)

As a side effect, logs are now more detailed:

info: ____._________.E2E.Framework.Services._____[0]
      Oryx:  GET https://___________/v1/login/initialize
→ (null)
← { StatusCode = OK
       Headers =
         map
           [("Cache-Control", [|"no-store, must-revalidate, no-cache, private"|]);
            ("Connection", [|"keep-alive"|]);
            ("Date", [|"Tue, 22 Nov 2022 17:21:25 GMT"|]);
            ("Strict-Transport-Security", [|"max-age=15724800; includeSubDomains"|]);
            ("Vary", [|"Origin"; "Cookie"|])]
        Data = { Id = "___________________" } }